From: jenkins-bot Date: Tue, 6 Dec 2016 18:14:41 +0000 (+0000) Subject: Merge "Convert Special:DeletedContributions to use OOUI." X-Git-Tag: 1.31.0-rc.0~4663 X-Git-Url: https://git.heureux-cyclage.org/?p=lhc%2Fweb%2Fwiklou.git;a=commitdiff_plain;h=e758226c91935a1df2b6fd3ed1f18922d8bfb45b;hp=03d1d295b99713bbe4657d26448bb7fc2b57d013 Merge "Convert Special:DeletedContributions to use OOUI." --- diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000000..044dd7202d --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": "wikimedia", + "env": { + "browser": true, + "jquery": true, + "qunit": true + }, + "globals": { + "require": false, + "module": false, + "mediaWiki": false, + "mwPerformance": false, + "OO": false + }, + "rules": { + "dot-notation": 0 + } +} diff --git a/.gitreview b/.gitreview index 0ec44b8359..148be026f5 100644 --- a/.gitreview +++ b/.gitreview @@ -2,5 +2,5 @@ host=gerrit.wikimedia.org port=29418 project=mediawiki/core.git -defaultbranch=master +track=1 defaultrebase=0 diff --git a/.jscsrc b/.jscsrc deleted file mode 100644 index f3db218447..0000000000 --- a/.jscsrc +++ /dev/null @@ -1,37 +0,0 @@ -{ - "preset": "wikimedia", - "es3": true, - - "requireVarDeclFirst": null, - - "requireDotNotation": { "allExcept": [ "keywords" ] }, - "jsDoc": { - "checkAnnotations": { - "preset": "jsduck5", - "extra": { - "context": "some", - "source": "some", - "see": "some" - } - }, - "checkParamNames": true, - "checkRedundantAccess": true, - "checkRedundantReturns": true, - "checkTypes": "strictNativeCase", - "requireNewlineAfterDescription": true, - "requireParamTypes": true, - "requireReturnTypes": true - }, - - "excludeFiles": [ - "docs/**", - "extensions/**", - "node_modules/**", - "resources/lib/**", - "resources/src/jquery.tipsy/**", - "resources/src/jquery/jquery.farbtastic.js", - "resources/src/mediawiki.libs/**", - "skins/**", - "vendor/**" - ] -} diff --git a/.jshintignore b/.jshintignore deleted file mode 100644 index fdde7d054d..0000000000 --- a/.jshintignore +++ /dev/null @@ -1,12 +0,0 @@ -# Generated documentation -docs/** - -# third-party libs -extensions/** -node_modules/** -resources/lib/** -resources/src/jquery.tipsy/** -resources/src/jquery/jquery.farbtastic.js -resources/src/mediawiki.libs/** -skins/** -vendor/** diff --git a/.jshintrc b/.jshintrc deleted file mode 100644 index 441c4e310b..0000000000 --- a/.jshintrc +++ /dev/null @@ -1,33 +0,0 @@ -{ - // Enforcing - "bitwise": true, - "eqeqeq": true, - "esversion": 3, - "freeze": true, - "futurehostile": true, - "latedef": "nofunc", - "noarg": true, - "nonew": true, - "strict": false, - "undef": true, - "unused": true, - - // Relaxing - "laxbreak": true, - "multistr": true, - - // Environment - "browser": true, - - "globals": { - "require": false, - "module": false, - "mediaWiki": true, - "JSON": true, - "OO": true, - "mwPerformance": true, - "jQuery": false, - "QUnit": false, - "sinon": false - } -} diff --git a/.mailmap b/.mailmap index 5c82af8118..dd968e8d08 100644 --- a/.mailmap +++ b/.mailmap @@ -1,30 +1,62 @@ +# Map author and committer names and email addresses to canonical real names +# and email addresses. +# +# To update the CREDITS file, run maintenance/updateCredits.php +# +# Two types of entries are useful here. The first sets a canonical author +# name for a given email address: +# +# Canonical Author Name +# +# The second allows collecting alternate email addresses into a single +# canonical author name and email address: +# +# Canonical Author Name +# +# Mappings are only needed for authors who have used multiple author names +# and/or author emails for revisions over time. Author names beginning with +# "[BOT]" will be omitted from the CREDITS file. +# +# See also: https://git-scm.com/docs/git-shortlog#_mapping_authors +# +[BOT] Gerrit Code Review [BOT] Gerrit Patch Uploader +[BOT] jenkins-bot +[BOT] jenkins-bot [BOT] Translation updater bot Aaron Schulz Aaron Schulz Adam Roses Wight +Adam Roses Wight addshore +Aditya Sastry Adrian Heine -Alex Monk -Alex Monk -Alex Z -Alexander Emsenhuber -Alexander Emsenhuber -Alexander Emsenhuber +Alex Z. +Alexandre Emsenhuber +Alexandre Emsenhuber +Alexandre Emsenhuber +Alexander Monk +Alexander Monk +Alexander Monk Alexia E. Smith Amir E. Aharoni Amir E. Aharoni +Amir Sarabadani Anders Wegge Jakobsen Andre Engels +Andrew Garrett Andrew Garrett Angela Beesley Starling Antoine Musso Antoine Musso Aran Dunkley Ariel Glenn +Ariel Glenn Arlo Breault +Arthur Richards Arthur Richards Aryeh Gregor +Asher Feldman Asher Feldman aude Audrey Tang @@ -32,25 +64,30 @@ Audrey Tang ayush_garg Bahodir Mansurov Bartosz Dziewoński -Bartosz Dziewoński Bartosz Dziewoński +Bartosz Dziewoński Ben Hartshorne Bene -Benjamin Lees +Bene +Benny Situ Benny Situ Bertrand Grondin Brad Jorsch +Brad Jorsch Brandon Harris -Brian Wolff Brian Wolff +Brian Wolff +Brian Wolff Brion Vibber Brion Vibber Brion Vibber Bryan Davis +Bryan Davis +Bryan Tong Minh Bryan Tong Minh C. Scott Ananian C. Scott Ananian -cacycle@gerrit.wikimedia.org +Cacycle cenarium Chad Horohoe Chad Horohoe @@ -58,44 +95,64 @@ Charles Melbye Chiefwei Chris McMahon Chris Steipp -Christian Aistleitner Christian Aistleitner +Christian Aistleitner Christian Williams Christian Williams Christian Williams +Christopher Johnson +church of emacs +Cindy Cicalese ckoerner Conrad Irwin Dan Duvall dan-nl Daniel A. R. Werner Daniel Cannon +Daniel Friesen +Daniel Friesen Daniel Friesen +Daniel Friesen Daniel Kinzler Daniel Kinzler -Danny B +Danny B. +Danny B. +Danny B. +Danny B. +Darian Anthony Patrick +Darkdragon09 David Chan +Dereckson +Derk-Jan Hartman +Derk-Jan Hartman Derk-Jan Hartman -Derk-Jan Hartman Diederik van Liere Domas Mituzas Douglas Gardner DPStokesNZ Ebrahim Byagowi Ed Sanders -Elliott Eggleston +Elliott Eggleston +Elliott Eggleston Emmanuel Engelhart -eranroz +Emufarmers +Emufarmers +Entlinkt +Eranroz Erik Bernhardson Erik Moeller Erik Moeller Erwin Dokter Evan McIntire +Evan Prodromou Federico Leva Fenzik Joseph -Florianschmidtwelzow -Florianschmidtwelzow Florian -Fomafix +Florian Schmidt +Florian Schmidt +fomafix +Fran Rogers Fran Rogers +freakolowsky FunPika Gabriel Wicke Gabriel Wicke @@ -110,31 +167,38 @@ glaisher Greg Sabino Mullane Greg Sabino Mullane Greg Sabino Mullane +Grunny Guy Van den Broeck Happy-melon Helder Helder Hoo man +Huji Huji Ian Baker Ilmari Karonen Inez Korczyński Inez Korczyński isarra +isarra Ivan Lanin -Jack Phoenix Jack Phoenix +Jack Phoenix Jackmcbarn -Jackmcbarn +Jackmcbarn jagori -James D. Forrester +James Forrester Jan Gerber +Jan Luca Naumann Jan Luca Naumann Jan Paul Posma Jan Zerebecki +Jared Flores Jaroslav Škarvada jarrettmunton +Jason Richey Jason Richey +Jason Richey Jeff Hall Jeff Hall Jeff Janes @@ -151,40 +215,58 @@ Jon Robson Juliusz Gonera Juliusz Gonera JuneHyeon Bae +Jure Kajzer Jure Kajzer +Karun Dambiec +Katie Filbert Katie Filbert Kevin Israel -Kunal Mehta -Kunal Mehta +Kunal Grover +Kunal Mehta +Kunal Mehta +Kunal Mehta Kwan Ting Chan lekshmi Leo Koppelkamm +Leon Liesener Leon Weber Leonardo Gregianin Leons Petrazickis -Liangent +liangent Lisa Ridley Ljudusika Luis Felipe Schenone +Lupo m4tx +Madman Magnus Manske Manuel Schneider <80686@users.mediawiki.org> +Marc-André Pelletier +Marcin Cieślak Marcin Cieślak +Marco Falke +MarcoAurelio Marielle Volz Marius Hoch -Mark A. Hershberger -Mark A. Hershberger -Mark A. Hershberger Mark Clements +Mark Hershberger +Mark Hershberger +Mark Hershberger +Mark Hershberger Mark Holmquist +Mark Holmquist Marko Obrovac +Markus Glaser +Markus Glaser Matt Johnston Matthew Britton Matthew Flaschen Matthias Mullie +Matthias Mullie Matěj Grabovský Max Semenik Max Semenik +Max Semenik mgooley Michael Dale mjbmr @@ -192,23 +274,30 @@ Mohamed Magdy Moriel Schottlender Moriel Schottlender Mormegil +MrBlueSky +MrBlueSky Mukunda Modell +Mwalker MZMcBride nadeesha Namit Nathaniel Herman Neil Kandalgaonkar Nemo bis -Nephele +nephele Nick Jenkins Nik Everett Niklas Laxström Niklas Laxström Nimish Gautam Nuria Ruiz -Ori.livneh +Ori Livneh +Ori Livneh OverlordQ +Owen Davis +Owen Davis paladox +Patrick Reilly Patrick Reilly Patrick Westerhoff Paul Copperman @@ -223,9 +312,9 @@ PranavK Prateek Saxena Prateek Saxena Priyanka Dhanda -Purodha B Blissenbach -Purodha B Blissenbach -Purodha B Blissenbach +Purodha Blissenbach +Purodha Blissenbach +Purodha Blissenbach Raimond Spekking Raimond Spekking Remember the dot @@ -233,13 +322,15 @@ Reza Ricordisamoa rillke rillke -River Tarnell River Tarnell +River Tarnell Roan Kattouw Roan Kattouw Roan Kattouw Rob Church +Rob Lanphier Rob Lanphier +Rob Lanphier Rob Moen Rob Moen Rob Moen @@ -247,24 +338,30 @@ Robert Hoenig Robert Leverington Robert Rohde Robert Stojnić +Robin Pepermans Robin Pepermans robinhood701 Rohan Rotem Liss Rummana Yasmeen Russ Nelson -Ryan Kaldari Ryan Kaldari Ryan Kaldari +Ryan Kaldari +Ryan Lane Ryan Lane +Ryan Lane +Ryan Schmidt +Ryan Schmidt Ryan Schmidt S Page Sam Reed +Sam Reed +Sam Reed Sam Reed Sam Smith -Santhosh Thottingal Santhosh Thottingal -saper +Santhosh Thottingal Schnark Scimonster Sean Colombo @@ -272,11 +369,14 @@ Sean Pringle Seb35 Sergio Santoro Shahyar +Shinjiman Shinjiman Siebrand Mazeland Siebrand Mazeland Siebrand Mazeland Siebrand Mazeland +Smriti Singh +Sorawee Porncharoenwase Southparkfan SQL Stanislav Malyshev @@ -288,9 +388,10 @@ Steven Roddis Subramanya Sastry Sucheta Ghoshal Sumit Asthana +Swalling Thalia Chan -TheDJ Thiemo Mättig (WMDE) +Thiemo Mättig (WMDE) This, that and the other tholam Thomas Bleher @@ -305,29 +406,45 @@ Timo Tijhof Timo Tijhof Timo Tijhof Tina Johnson +Tisane +Tjones Tom Maaswinkel Tomasz Finc +Tomasz W. Kozlowski +Tomasz W. Kozlowski +Tomasz W. Kozlowski Tony Thomas <01tonythomas@gmail.com> +Tpt Trevor Parscal Trevor Parscal Trevor Parscal Tyler Cipriani Tyler Romeo -umherirrender +Umherirrender +Victor Vasiliev Victor Vasiliev +Victor Vasiliev Vikas S Yaligar Vivek Ghaisas wctaiwan withoutaname X! +Yaron Koren +Yaron Koren Yaroslav Melnychuk +Yongmin Hong +Yongmin Hong +Yongmin Hong Yuri Astrakhan +Yuri Astrakhan Yuri Astrakhan Yusuke Matsubara -YuviPanda +Yuvi Panda Zak Greant +Zhengzhu Feng +Zhengzhu Feng +Zppix Ævar Arnfjörð Bjarmason +Étienne Beaulé Željko Filipin Željko Filipin -Zhengzhu Feng -Zhengzhu Feng diff --git a/.stylelintrc b/.stylelintrc new file mode 100644 index 0000000000..62dbeb69ed --- /dev/null +++ b/.stylelintrc @@ -0,0 +1,26 @@ +{ + "rules": { + "color-hex-case": [ "lower" ], + "color-hex-length": [ "short" ], + "color-named": [ "never" ], + "color-no-invalid-hex": true, + + "declaration-bang-space-after": [ "never" ], + "declaration-bang-space-before": [ "always" ], + "declaration-colon-space-after": [ "always" ], + "declaration-colon-space-before": [ "never" ], + + "font-family-name-quotes": [ "always-unless-keyword" ], + "font-weight-notation": [ "named-where-possible" ], + + "function-calc-no-unspaced-operator": true, + "function-comma-newline-after": "never-multi-line", + "function-comma-newline-before": "never-multi-line", + "function-comma-space-after": [ "always" ], + "function-comma-space-before": [ "never" ], + "function-parentheses-newline-inside": [ "never-multi-line" ], + "function-parentheses-space-inside": [ "always" ], + "function-url-quotes": [ "never" ], + "function-whitespace-after": [ "always" ], + } +} diff --git a/.travis.yml b/.travis.yml index 9062194628..973860569d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -55,7 +55,6 @@ notifications: email: false irc: channels: - - "chat.freenode.net#mediawiki-core" - "chat.freenode.net#mediawiki-feed" on_success: change on_failure: change diff --git a/CREDITS b/CREDITS index a54bd90b53..d9ff9709f1 100644 --- a/CREDITS +++ b/CREDITS @@ -1,256 +1,661 @@ {{int:version-credits-summary}} -== Developers == -* Aaron Schulz -* Alex Z. -* Alexander Monk -* Alexandre Emsenhuber -* Andrew Garrett -* Antoine Musso -* Arthur Richards -* Aryeh Gregor -* Bartosz Dziewoński -* Bertrand Grondin -* Brad Jorsch -* Brian Wolff -* Brion Vibber -* Bryan Davis -* Bryan Tong Minh -* Chad Horohoe -* Charles Melbye -* Chris Steipp -* church of emacs -* Daniel Friesen -* Daniel Kinzler -* Daniel Renfro -* Danny B. -* David McCabe -* Derk-Jan Hartman -* Domas Mituzas -* Emufarmers -* Fran Rogers -* Greg Sabino Mullane -* Guy Van den Broeck -* Happy-melon -* Hojjat -* Ian Baker -* Ilmari Karonen -* Jack D. Pond -* Jack Phoenix -* Jackmcbarn -* James Forrester -* Jan Paul Posma -* Jason Richey -* Jeroen De Dauw -* John Du Hart -* Jon Harald Søby -* Juliano F. Ravasi -* Leo Koppelkamm -* Leon Weber -* Leslie Hoare -* Marco Schuster -* Marius Hoch -* Matěj Grabovský -* Matt Johnston -* Matthew Flaschen -* Max Semenik -* Meno25 -* MinuteElectron -* Mohamed Magdy -* Nathaniel Herman -* Neil Kandalgaonkar -* Nicolas Dumazet -* Niklas Laxström -* Ori Livneh -* Patrick Reilly -* Philip Tzou -* Platonides -* Purodha Blissenbach -* Raimond Spekking -* Remember the dot -* Roan Kattouw -* Robert Stojnić -* Robin Pepermans -* Rotem Liss -* Ryan Kaldari -* Ryan Lane -* Ryan Schmidt -* Sam Reed -* Shinjiman -* Siebrand Mazeland -* Soxred93 -* SQL -* Szymon Świerkosz -* This, that and the other -* Thomas Bleher -* Thomas Gries -* Tim Starling -* Timo Tijhof -* Trevor Parscal -* Tyler Anthony Romeo -* Victor Vasiliev -* Yesid Carrillo -* Yuri Astrakhan - -== Patch Contributors == +== Contributors == + + +* aalekhN * Aaron Ball * Aaron Pramana +* Aaron Schulz +* Aarti Dwivedi +* Aashaka Shah +* abhinand +* Abhishek Das +* Adam Miller +* Adam Roses Wight +* addshore +* Aditya Sastry +* Adrian Heine +* Adrian Lang +* Ævar Arnfjörð Bjarmason * Agbad * Ahmad Sherif +* Ajayrahul P +* Alangi Derick +* Albert221 * Alejandro Mery +* AlephNull +* Alex Ivanov +* Alex Shih-Han Lin +* Alex Z. +* Alexander I. Mashin +* Alexander Lehmann +* Alexander Monk +* Alexander Sigachov +* Alexandre Emsenhuber +* Alexia E. Smith * Amalthea * Amir E. Aharoni +* Amir Sarabadani +* ananay +* Anders Wegge Jakobsen +* Andre Engels +* Andrew Bogott * Andrew Dunbar +* Andrew Garrett +* Andrew Green +* Andrew H +* Andrew Harris +* Andrew Otto +* Andrius R +* andymw +* Angela Beesley Starling +* ankur +* Antoine Musso * Antonio Ospite +* apexkid +* April King +* Aran Dunkley +* Arash Boostani +* Arcane21 +* Ariel Glenn +* Arlo Breault +* Arne Heizmann +* Arthur Richards +* Aryeh Gregor +* Asher Feldman * Asier Lostalé +* ayush_garg * Azliq7 * Bagariavivek +* Bahodir Mansurov +* balloonguy +* Bartosz Dziewoński * Beau +* Ben Davis +* Ben Hartshorne +* Bene * Benny Situ * Bergi +* Bertrand Grondin +* Bill Traynor +* Billinghurst +* billm +* blotmandroid +* Bogdan Stancescu +* Boris Nagaev * Borislav Manolov +* Brad Jorsch +* Brandon Black +* Brandon Harris * Brent G +* Brent Garber +* Brian Wolff * Brianna Laugher +* Brion Vibber +* Bryan Davis +* Bryan Tong Minh +* burthsceh +* C. Scott Ananian +* Cacycle +* Calak +* Camille Constans +* Carl Fürstenberg * Carlin * Carsten Nielsen +* Cblair91 +* cenarium +* Chad Horohoe +* Charles Melbye +* Chiefwei +* Chris McMahon +* Chris Seaton +* Chris Steipp * Christian Aistleitner +* Christian List * Christian Neubauer +* Christopher Johnson +* church of emacs +* Cindy Cicalese +* ckoerner * Conrad Irwin * cryptocoryne * Dan Barrett * Dan Collins +* Dan Duvall * Dan Nessett +* Dan Poltawski +* dan-nl +* Daniel A. R. Werner * Daniel Arnold +* Daniel Cannon +* Daniel De Marco +* Daniel Evans +* Daniel Friesen +* Daniel Kinzler +* Daniel Renfro * Daniel Werner +* DanielRenfro +* Danny B. +* Darian Anthony Patrick +* Darkdragon09 +* DaSch * David Baumgarten +* David Chan +* David E. Narváez +* David Lynch +* David McCabe +* David Mudrák +* dcausse +* dennisroczek * Denny Vrandecic +* Dereckson +* Derk-Jan Hartman +* Derric Atzrott +* Derrick Coetzee * Dévai Tamás +* Devi Krishnan +* Diederik van Liere +* Domas Mituzas +* Douglas Gardner +* DPStokesNZ +* dr0ptp4kt * Ebrahim Byagowi +* Ed Sanders +* Edward Chernenko * Edward Z. Yang +* Elisabeth Bauer +* Elliott Eggleston * Elvis Stansvik +* Emil Podlaszewski +* Emmanuel Engelhart +* Emmanuel Gil Peyrot +* Emmet Hikory +* Emufarmers +* enigmaeth +* Entlinkt * Eranroz +* Eric Evans +* Eric Schneider +* Erich Lerch +* Erick Guan +* Erik Bernhardson +* Erik Moeller * Erwin Dokter * Étienne Beaulé +* Evan McIntire +* Evan Prodromou +* ExplosiveHippo +* Faidon Liambotis * Federico Leva +* Fenzik Joseph +* firebus * Florian Schmidt * fomafix +* Fran Rogers +* Fred Emmott * FunPika * Gabriel Wicke +* Gary Guo +* gbt248 * Geoffrey Mon +* georggi +* Gergő Tisza * Gero Scholz +* gicode +* Giftpflanze +* Gilles Dubuc * Gilles van den Hoven +* Giuseppe Lavagetto +* gladoscc +* glaisher +* Greg Maxwell +* Greg Sabino Mullane +* Gregory Szorc * Grunny +* Guillaume Blanchard +* Guy Van den Broeck +* Happy-melon +* haritha28 * Harry Burt +* Hazard-SJ +* Hector A Escobedo +* Helder +* Henning Snater +* Hojjat +* Huji +* Hydriz +* Ian Baker +* Ilmari Karonen +* Inez Korczyński +* IoannisKydonis * Ireas +* isarra +* Ivan Lanin +* Jack D. Pond +* Jack Phoenix +* Jackmcbarn * Jacob Block +* Jacob Clark +* jagori +* Jakub Vrana +* James Earl Douglas +* James Forrester +* Jan Berkel +* Jan Drewniak * Jan Gerber * Jan Luca Naumann +* Jan Paul Posma +* Jan Zerebecki +* Jared Flores +* Jaroslav Škarvada +* jarrettmunton +* jarry1250 * Jaska Zedlik +* Jason Richey +* jeblad +* Jeff Janes +* jeff303 +* Jens Frank +* Jens Ohlig +* Jérémie Roquet * Jeremy Baron +* Jeremy Postlethwaite +* jeremyb +* Jeroen De Dauw +* Jerome Jamnicky +* Jesús Martínez Novo +* jhobs +* Jiabao * Jidanni +* Jimmy Collins * Jimmy Xu +* joakin +* Joan Creus +* Joel Natividad +* Joerg +* Johan Dahlin +* John Du Hart * John N +* Jon Harald Søby +* Jon Robson * Jonathan Wiltshire +* Jools Wills +* jsahleen +* Julian Ostrow +* Juliano F. Ravasi +* Juliusz Gonera * JuneHyeon Bae * Jure Kajzer +* Justin Du +* Kai_WMDE +* kaligula +* Kartik Mistry * Karun Dambiec * Katie Filbert * Kevin Israel +* Kghbln +* Kim Eik * Kim Hyun-Joon +* kipod +* kishanio +* konarak +* krishna keshav +* Krzysztof Krzyzaniak +* Krzysztof Zbudniewek +* Kunal Grover +* Kunal Mehta +* Kwan Ting Chan +* Laurence Parry +* Lee Bousfield +* Lee Daniel Crocker * Lee Worden * Lejonel +* lekshmi +* Leo Koppelkamm * Leon Liesener +* Leon Weber +* Leonardo Gregianin +* Leons Petrazickis +* Leslie Hoare +* Leszek Manicki +* lethosor +* Lewis Cawte +* Liam Edwards-Playne * liangent +* Lisa Ridley +* Ljudusika +* Lojjik Braughler * Louperivois +* Ltrlg +* Luc Van Oostenryck * Lucas Garczewski * Luigi Corsaro +* Luis Felipe Schenone * Luke Faraone +* Lupin * Lupo +* lwelling +* m4tx * Madman +* madurangasiriwardena +* Magnus Manske * Manuel Menal +* Manuel Schneider +* Marc Ordinas i Llopis * Marc-André Pelletier * Marcin Cieślak +* Marco Falke +* Marco Schuster +* MarcoAurelio * Marcus Buck +* Marius Hoch +* Mark Bergsma +* Mark Clements * Mark Hershberger * Mark Holmquist +* Marko Obrovac +* Markus Glaser +* Markus Krötzsch * Marooned +* Martin Urbanec +* Massaf +* Matěj Grabovský +* matejsuchanek * Mathias Ertl * mati +* Matt Fitzpatrick +* Matt Johnston +* Matt Russell +* Matthew Bowker * Matthew Britton +* Matthew Flaschen +* Matthias Jordan * Matthias Mullie +* MatthiasDD * Max +* Max Semenik * Max Sikström +* mayankmadan +* Meno25 * merl +* Merlijn S. van Deen +* MGChecker +* mgooley +* mhutti1 * Michael Dale * Michael De La Rue +* Michael Holloway * Michael M. * Michael Newton * Michael Walsh +* Michał Łazowik +* Michał Roszka +* Michał Zieliński * Mike Horvath +* Minh Nguyễn +* MinuteElectron +* Misza13 +* mjbmr * moejoe0000 +* Mohamed Magdy +* Molly White +* Moriel Schottlender * Mormegil +* Mr. E23 * MrBlueSky * MrPete +* Mukunda Modell +* Mwalker +* mwjames * mybugs.mail * MZMcBride +* nadeesha * Nakon +* Namit * Nathan Larson +* Nathaniel Herman +* Neil Kandalgaonkar +* Nemo bis * nephele +* Nicholas Pisarro, Jr +* Nick Jenkins +* nicoco007 +* Nicolas Dumazet +* Nicolas Weeger * Nik +* Nik Everett +* Niklas Laxström * Nikola Kovacs +* Nikola Smolenski * Nikolaos S. Karastathis +* Nimish Gautam * Nischay Nahata +* nischayn22 +* nomoa +* nullspoon +* Nuria Ruiz * Nx.devnull +* Ocean behind ears * Olaf Lenz * Olivier Finlay Beaton +* onei +* opatel99 +* Oren Held +* Ori Livneh +* oskar.jauch@gmail.com +* OverlordQ +* Owen Davis +* Paa Kwesi Imbeah +* paladox * Patricio Molina +* Patrick Reilly +* Patrick Westerhoff +* Pau Giner * Paul Copperman * Paul Oranje +* Pavel Astakhov +* Pavel Selitskas +* Pcoombe +* Perside Rosalie * Peter Gehres +* Peter Hedenskog +* Peter Potrowl +* Petr Bena +* Petr Kadlec * Petr Onderka +* Petr Pchelko +* Philip Tzou +* physikerwelt (Moritz Schubotz) * PieRRoMaN +* Pikne +* PiRSquared17 +* Platonides +* Pmlineditor +* pmolina +* prageck +* Pranav Ravichandran +* PranavK +* Prateek Saxena +* Priyanka Dhanda +* Prod +* ptarjan +* pubudu538 +* Purodha Blissenbach +* quiddity * quietust +* Quim Gil +* rahul21 +* Raimond Spekking +* Ramunas Geciauskas +* Remember the dot * René Kijewski +* Reza * rgcjonas +* Ricordisamoa +* rillke +* River Tarnell +* Roan Kattouw +* Rob Church +* Rob Lanphier * Rob Moen +* Robert Hoenig +* Robert Leverington +* Robert Rohde +* Robert Stojnić * Robert Treat +* Robert Vogel +* Robin Pepermans +* robinhood701 * RockMFR +* Rohan +* Roman Nosov +* Roman Tsukanov +* Rotem Liss +* Rowan Collins +* Russ Nelson * Russell Blau * Rusty Burchfield +* Ruud Koot +* Ryan Bies +* Ryan Finnie +* Ryan Kaldari +* Ryan Lane +* Ryan Schmidt * S Page * Salvatore Ingala +* Sam Reed +* Sam Smith * Santhosh Thottingal +* Schnark +* Scimonster +* scnd * Scott Colcord * se4598 +* Sean Colombo +* Sean Pringle +* Seb35 +* Sebastian Brückner * Sébastien Santoro +* Sergio Santoro +* Sethakill +* Shahyar +* Shane Gibbons +* Shane King +* Shinjiman +* shirayuki +* Sidhant Gupta +* Siebrand Mazeland * Simon Walker +* Smriti Singh * Solitarius +* Sorawee Porncharoenwase * Søren Løvborg * Southparkfan +* Soxred93 +* SQL * Srikanth Lakshmanan +* Stanislav Malyshev * Stefano Codari +* Steinsplitter +* Stephan Gambke +* Stephan Muggli +* Stephane Bisson +* Stephen Liang +* Steve Sanbeg +* Steven Roddis * Str4nd * Subramanya Sastry +* Sumit Asthana * svip +* Swalling +* Szymon Świerkosz +* T.D. Corell +* Tarquin +* The Discoverer * The Evil IP address +* theopolisme +* Thiemo Mättig (WMDE) +* This, that and the other +* tholam +* Thomas Arrow +* Thomas Bleher +* Thomas Dalton +* Thomas Gries +* ThomasV +* Tim Hollmann * Tim Landscheidt +* Tim Laqua +* Tim Starling +* Timo Tijhof +* Tina Johnson * Tisane +* tjlsangria +* Tjones +* TK-999 +* Tobias Gritschacher +* Tom Arrow +* Tom Gilder +* Tom Maaswinkel +* Tomasz Finc +* Tomasz W. Kozlowski +* Tomasz Wegrzanowski +* tomek +* Tony Thomas +* Tpt +* Trevor Parscal +* TyA +* Tychay +* Tyler Anthony Romeo +* Tyler Cipriani +* Tyler Romeo +* U-REDMOND\emadelw +* UltrasonicNXT * Umherirrender +* utkarsh95 * Van de Bugger +* Viačeslav +* Victor Porton +* Victor Vasiliev +* victorbarbu * Ville Stadista +* vishnu * Vitaliy Filippov * Vivek Ghaisas +* vlakoff +* Volker E * Waldir Pimenta +* wctaiwan +* Wikinaut +* Wil Mahan * William Demchick +* withoutaname +* WMDE-Fisch +* X! +* XP1 +* Yaron Koren +* Yaroslav Melnychuk +* Yesid Carrillo +* Yogesh K S +* Yongmin Hong +* yoonghm +* Yuri Astrakhan * Yusuke Matsubara * Yuvi Panda * Zachary Hauri +* Zak Greant +* Željko Filipin +* Zhaofeng Li +* Zhengzhu Feng +* Zppix +* محمد شعیب + == Translators == diff --git a/FAQ b/FAQ index cfacf14676..29017bcb5d 100644 --- a/FAQ +++ b/FAQ @@ -1,2 +1,2 @@ The MediaWiki FAQ can be found at: -https://www.mediawiki.org/wiki/Manual:FAQ +https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ \ No newline at end of file diff --git a/Gemfile b/Gemfile index fa3a025ef0..8a349bf040 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,5 @@ source 'https://rubygems.org' -gem 'mediawiki_selenium', '~> 1.7' +gem 'mediawiki_selenium', '~> 1.7', '>= 1.7.2' gem 'rake', '~> 11.1', '>= 11.1.1' gem 'rubocop', '~> 0.32.1', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 2bbabd1b7c..982619abde 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -17,16 +17,18 @@ GEM faker (>= 1.1.2) yml_reader (>= 0.6) diff-lcs (1.2.5) - domain_name (0.5.20160310) + domain_name (0.5.20160615) unf (>= 0.0.5, < 1.0.0) - faker (1.6.3) + faker (1.6.6) i18n (~> 0.5) faraday (0.9.2) multipart-post (>= 1.2, < 3) faraday-cookie_jar (0.0.6) faraday (>= 0.7.4) http-cookie (~> 1.0.0) - ffi (1.9.10) + faraday_middleware (0.10.0) + faraday (>= 0.7.4, < 0.10) + ffi (1.9.14) gherkin (2.12.2) multi_json (~> 1.3) headless (2.2.3) @@ -34,26 +36,27 @@ GEM domain_name (~> 0.5) i18n (0.7.0) json (1.8.3) - mediawiki_api (0.5.0) + mediawiki_api (0.7.0) faraday (~> 0.9, >= 0.9.0) faraday-cookie_jar (~> 0.0, >= 0.0.6) - mediawiki_selenium (1.7.0) + faraday_middleware (~> 0.10, >= 0.10.0) + mediawiki_selenium (1.7.2) cucumber (~> 1.3, >= 1.3.20) headless (~> 2.0, >= 2.1.0) json (~> 1.8, >= 1.8.1) - mediawiki_api (~> 0.5, >= 0.5.0) + mediawiki_api (~> 0.7, >= 0.7.0) page-object (~> 1.0) rest-client (~> 1.6, >= 1.6.7) rspec-core (~> 2.14, >= 2.14.4) rspec-expectations (~> 2.14, >= 2.14.4) syntax (~> 1.2, >= 1.2.0) thor (~> 0.19, >= 0.19.1) - mime-types (2.99.1) - multi_json (1.11.3) + mime-types (2.99.2) + multi_json (1.12.1) multi_test (0.1.2) multipart-post (2.0.0) netrc (0.11.0) - page-object (1.1.1) + page-object (1.2.0) page_navigation (>= 0.9) selenium-webdriver (>= 2.44.0) watir-webdriver (>= 0.6.11) @@ -79,16 +82,16 @@ GEM ruby-progressbar (~> 1.4) ruby-progressbar (1.7.5) rubyzip (1.2.0) - selenium-webdriver (2.53.0) + selenium-webdriver (2.53.4) childprocess (~> 0.5) rubyzip (~> 1.0) websocket (~> 1.0) - syntax (1.2.0) + syntax (1.2.1) thor (0.19.1) unf (0.1.4) unf_ext unf_ext (0.0.7.2) - watir-webdriver (0.9.1) + watir-webdriver (0.9.3) selenium-webdriver (>= 2.46.2) websocket (1.2.3) yml_reader (0.7) @@ -97,9 +100,6 @@ PLATFORMS ruby DEPENDENCIES - mediawiki_selenium (~> 1.7) + mediawiki_selenium (~> 1.7, >= 1.7.2) rake (~> 11.1, >= 11.1.1) rubocop (~> 0.32.1) - -BUNDLED WITH - 1.10.6 diff --git a/Gruntfile.js b/Gruntfile.js index 354f0483b6..55b7932f00 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,32 +1,44 @@ -/*jshint node:true */ +/* eslint-env node */ + module.exports = function ( grunt ) { - grunt.loadNpmTasks( 'grunt-contrib-copy' ); - grunt.loadNpmTasks( 'grunt-contrib-jshint' ); - grunt.loadNpmTasks( 'grunt-contrib-watch' ); - grunt.loadNpmTasks( 'grunt-banana-checker' ); - grunt.loadNpmTasks( 'grunt-jscs' ); - grunt.loadNpmTasks( 'grunt-jsonlint' ); - grunt.loadNpmTasks( 'grunt-karma' ); var wgServer = process.env.MW_SERVER, wgScriptPath = process.env.MW_SCRIPT_PATH, karmaProxy = {}; + grunt.loadNpmTasks( 'grunt-banana-checker' ); + grunt.loadNpmTasks( 'grunt-contrib-copy' ); + grunt.loadNpmTasks( 'grunt-contrib-watch' ); + grunt.loadNpmTasks( 'grunt-eslint' ); + grunt.loadNpmTasks( 'grunt-jsonlint' ); + grunt.loadNpmTasks( 'grunt-karma' ); + grunt.loadNpmTasks( 'grunt-stylelint' ); + karmaProxy[ wgScriptPath ] = wgServer + wgScriptPath; grunt.initConfig( { - jshint: { - options: { - jshintrc: true - }, - all: '.' - }, - jscs: { - all: '.' + eslint: { + all: [ + '**/*.js', + '!docs/**', + '!tests/**', + '!node_modules/**', + '!resources/lib/**', + '!resources/src/jquery.tipsy/**', + '!resources/src/jquery/jquery.farbtastic.js', + '!resources/src/mediawiki.libs/**', + '!vendor/**', + // Explicitly say "**/*.js" here in case of symlinks + '!extensions/**/*.js', + '!skins/**/*.js', + // Skip functions aren't even parseable + '!resources/src/dom-level2-skip.js', + '!resources/src/es5-skip.js', + '!resources/src/mediawiki.hidpi-skip.js' + ] }, jsonlint: { all: [ - '.jscsrc', '**/*.json', '!{docs/js,extensions,node_modules,skins,vendor}/**' ] @@ -39,9 +51,15 @@ module.exports = function ( grunt ) { api: 'includes/api/i18n/', installer: 'includes/installer/i18n/' }, + stylelint: { + options: { + syntax: 'less' + }, + src: '{resources/src/*,mw-config/**}/*.{css,less}' + }, watch: { files: [ - '.js*', + '.{stylelintrc,eslintrc.json}', '**/*', '!{docs,extensions,node_modules,skins,vendor}/**' ], @@ -96,7 +114,7 @@ module.exports = function ( grunt ) { return !!( process.env.MW_SERVER && process.env.MW_SCRIPT_PATH ); } ); - grunt.registerTask( 'lint', [ 'jshint', 'jscs', 'jsonlint', 'banana' ] ); + grunt.registerTask( 'lint', [ 'eslint', 'banana', 'stylelint' ] ); grunt.registerTask( 'qunit', [ 'assert-mw-env', 'karma:main' ] ); grunt.registerTask( 'test', [ 'lint' ] ); diff --git a/HISTORY b/HISTORY index e57d346316..28a9b869df 100644 --- a/HISTORY +++ b/HISTORY @@ -1,4 +1,911 @@ -Change notes from older releases. For current info see RELEASE-NOTES-1.27. +Change notes from older releases. For current info see RELEASE-NOTES-1.29. + +== MediaWiki 1.28 == + +=== Changes since 1.28.0-rc1 === +* (T148957) Replace wgShowExceptionDetails with wgShowDBErrorBacktrace on db + errors. +* (T148956) Only apply wgDBschema to postgres/mssql. +* (T145991) Introduce separate log action for deleting pages on move. +* (T141474) (T110464) Bypass login page if no user input is required. + +=== Changes since 1.28.0-rc0 === +* (T142210) The changes to move the parser "NewPP limit report" from a HTML + comment to a machine-readable JavaScript config option 'wgPageParseReport' + have been undone. They caused the human-readable limit report to be shown + incompletely or not at all. ParserOutput::setLimitReportData() and + getLimitReportData() behave as they did in MediaWiki 1.27 again. +* (T149510) Value of {{DISPLAYTITLE:}} parser function will not be used for + the text of subheadings on a category page when creating it. This wasn't + working correctly. +* (T106793) MediaWiki will no longer try to perform a HTTP redirect to the + canonical pretty URL when a non-pretty URL is used. It resulted in redirect + loops in some clients and in some server configurations. This undoes a change + made in MediaWiki 1.26. +* (T149759) manifest_version: 2 was removed. + +=== Configuration changes in 1.28 === +* $wgSend404Code now affects status code of action=history if the page is not there. +* BREAKING CHANGE: $wgHTTPProxy is now *required* for all external requests + made by MediaWiki via a proxy. Relying on the http_proxy environment + variable is no longer supported. +* The load.php entry point now enforces the existing policy of not allowing + access to session data, which includes the session user and the session + user's language. If such access is attempted, an exception will be thrown. +* The number of internal PBKDF2 iterations used to derive the session secret + is configurable via $wgSessionPbkdf2Iterations. +* Upload dialog's file upload log comment can now be configured separately for + local and foreign uploads. +* $wgForeignUploadTargets now defaults to `[ 'local' ]`, where `'local'` + signifies local uploads. A value of `[]` (empty array) now means that + no upload targets are allowed, effectively disabling the upload dialog. +* The deprecated $wgEditEncoding variable has been removed; it was only used + for Esperanto language character conversion. You are now recommended to use + input methods provided by the UniversalLanguageSelector extension. +* When $wgPingback is true, MediaWiki will periodically ping + https://www.mediawiki.org/beacon with basic information about the local + MediaWiki installation. This data includes, for example, the type of system, + PHP version, and chosen database backend. This behavior is off by default. +* When $wgEditSubmitButtonLabelPublish is true, MediaWiki will label the button + to store-to-database-and-show-to-others as "Publish page"/"Publish changes"; + if false, the default, they will be "Save page"/"Save changes". +* The 'editcontentmodel' permission is now granted to all logged-in users ('user'). + instead of just administrators ('sysop'). Documentation for this feature is + available at . +* $wgRevisionCacheExpiry is now set to one week by default instead of being disabled. +* Magic links are now disabled by default, and can be re-enabled by modifying the value + of $wgEnableMagicLinks. Their usage is discouraged, but if they are manually enabled, + a tracking category will be added to help identify usage and make it easier to migrate + away from. If you depend upon magic link functionality, it is requested that you comment + on and + explain your use case(s). +* New config variable $wgCSPFalsePositiveUrls to control what URLs to ignore + in upcoming Content-Security-Policy feature's reporting. + +=== New features in 1.28 === +* User::isBot() method for checking if an account is a bot role account. +* Added a new 'slideshow' mode for galleries. +* Added a new hook, 'UserIsBot', to aid in determining if a user is a bot. +* Added a new hook, 'ApiMakeParserOptions', to allow extensions to better + interact with API parsing. +* Added a new hook, 'UploadVerifyUpload', which can be used to reject a file + upload. Unlike 'UploadVerifyFile' it provides information about upload comment + and the file description page, but does not run for uploads to stash. +* (T141604) Extensions can now provide a better error message when their + maintenance scripts are run without the extension being installed. +* (T8948) Numeric sorting in categories is now supported by setting $wgCategoryCollation + to 'uca-default-u-kn' or 'uca--u-kn'. If you can't use UCA collations, + a 'numeric' collation is also available. If migrating from another + collation, you will need to run the updateCollation.php maintenance script. +* Two new codes have been added to #time parser function: "xit" for days in current + month, and "xiz" for days passed in the year, both in Iranian calendar. +* mw.Api has a new option, useUS, to use U+001F (Unit Separator) when + appropriate for sending multi-valued parameters. This defaults to true when + the mw.Api instance seems to be for the local wiki. +* After a client performs an action which alters a database that has replica databases, + MediaWiki will wait for the replica databases to synchronize with the master database + while it renders the HTML output. However, if the output is a redirect to another wiki + on the wiki farm with a different domain, MediaWiki will instead alter the redirect + URL to include a ?cpPosTime parameter that triggers the database synchronization when + the URL is followed by the client. The same-domain case uses a new cpPosTime cookie. +* Added new hooks, 'ApiQueryBaseBeforeQuery', 'ApiQueryBaseAfterQuery', and + 'ApiQueryBaseProcessRow', to make it easier for extensions to add 'prop' and + 'show' parameters to existing API query modules. + +=== External library changes in 1.28 === + +==== Upgraded external libraries ==== +* Updated es5-shim from v4.1.5 to v4.5.8 +* Updated composer/semver from v1.4.1 to v1.4.2 +* Updated wikimedia/php-session-serializer from v1.0.3 to v1.0.4 + +==== New external libraries ==== +* Added wikimedia/scoped-callback v1.0.0 +* Added wikimedia/wait-condition-loop v1.0.1 + +=== Bug fixes in 1.28 === +* (T146496) action=history pages should return 404 HTTP error code if the page does not exist +* (T137264) SECURITY: XSS in unclosed internal links +* (T133147) SECURITY: Escape '<' and ']]>' in inline ' . + "\n"; + + $hookResult = self::runHooks( $e, get_class( $e ) . 'Raw' ); + if ( $hookResult ) { + echo $hookResult; + } else { + echo self::getHTML( $e ); + } + + echo "\n"; + } + } + + /** + * If $wgShowExceptionDetails is true, return a HTML message with a + * backtrace to the error, otherwise show a message to ask to set it to true + * to show that information. + * + * @param Exception|Throwable $e + * @return string Html to output + */ + public static function getHTML( $e ) { + if ( self::showBackTrace( $e ) ) { + $html = "

" . + nl2br( htmlspecialchars( MWExceptionHandler::getLogMessage( $e ) ) ) . + '

Backtrace:

' . + nl2br( htmlspecialchars( MWExceptionHandler::getRedactedTraceAsString( $e ) ) ) . + "

\n"; + } else { + $logId = WebRequest::getRequestId(); + $html = "
" . + '[' . $logId . '] ' . + gmdate( 'Y-m-d H:i:s' ) . ": " . + self::msg( "internalerror-fatal-exception", + "Fatal exception of type $1", + get_class( $e ), + $logId, + MWExceptionHandler::getURL() + ) . "
\n" . + ""; + } + + return $html; + } + + /** + * Get a message from i18n + * + * @param string $key Message name + * @param string $fallback Default message if the message cache can't be + * called by the exception + * The function also has other parameters that are arguments for the message + * @return string Message with arguments replaced + */ + private static function msg( $key, $fallback /*[, params...] */ ) { + $args = array_slice( func_get_args(), 2 ); + try { + return wfMessage( $key, $args )->text(); + } catch ( Exception $e ) { + return wfMsgReplaceArgs( $fallback, $args ); + } + } + + /** + * @param Exception|Throwable $e + * @return string + */ + private static function getText( $e ) { + if ( self::showBackTrace( $e ) ) { + return MWExceptionHandler::getLogMessage( $e ) . + "\nBacktrace:\n" . + MWExceptionHandler::getRedactedTraceAsString( $e ) . "\n"; + } else { + return self::getShowBacktraceError( $e ); + } + } + + /** + * @param Exception|Throwable $e + * @return bool + */ + private static function showBackTrace( $e ) { + global $wgShowExceptionDetails, $wgShowDBErrorBacktrace; + + return ( + $wgShowExceptionDetails && + ( !( $e instanceof DBError ) || $wgShowDBErrorBacktrace ) + ); + } + + /** + * @param Exception|Throwable $e + * @return string + */ + private static function getShowBacktraceError( $e ) { + global $wgShowExceptionDetails, $wgShowDBErrorBacktrace; + $vars = []; + if ( !$wgShowExceptionDetails ) { + $vars[] = '$wgShowExceptionDetails = true;'; + } + if ( $e instanceof DBError && !$wgShowDBErrorBacktrace ) { + $vars[] = '$wgShowDBErrorBacktrace = true;'; + } + $vars = implode( ' and ', $vars ); + return "Set $vars at the bottom of LocalSettings.php to show detailed debugging information"; + } + + /** + * @return bool + */ + private static function isCommandLine() { + return !empty( $GLOBALS['wgCommandLineMode'] ); + } + + /** + * @param string $header + */ + private static function header( $header ) { + if ( !headers_sent() ) { + header( $header ); + } + } + + /** + * @param integer $code + */ + private static function statusHeader( $code ) { + if ( !headers_sent() ) { + HttpStatus::header( $code ); + } + } + + /** + * Print a message, if possible to STDERR. + * Use this in command line mode only (see isCommandLine) + * + * @param string $message Failure text + */ + private static function printError( $message ) { + // NOTE: STDERR may not be available, especially if php-cgi is used from the + // command line (bug #15602). Try to produce meaningful output anyway. Using + // echo may corrupt output to STDOUT though. + if ( defined( 'STDERR' ) ) { + fwrite( STDERR, $message ); + } else { + echo $message; + } + } + + /** + * @param Exception|Throwable $e + */ + private static function reportOutageHTML( $e ) { + global $wgShowDBErrorBacktrace, $wgShowHostnames, $wgShowSQLErrors; + + $sorry = htmlspecialchars( self::msg( + 'dberr-problems', + 'Sorry! This site is experiencing technical difficulties.' + ) ); + $again = htmlspecialchars( self::msg( + 'dberr-again', + 'Try waiting a few minutes and reloading.' + ) ); + + if ( $wgShowHostnames || $wgShowSQLErrors ) { + $info = str_replace( + '$1', + Html::element( 'span', [ 'dir' => 'ltr' ], htmlspecialchars( $e->getMessage() ) ), + htmlspecialchars( self::msg( 'dberr-info', '($1)' ) ) + ); + } else { + $info = htmlspecialchars( self::msg( + 'dberr-info-hidden', + '(Cannot access the database)' + ) ); + } + + MessageCache::singleton()->disable(); // no DB access + + $html = "

$sorry

$again

$info

"; + + if ( $wgShowDBErrorBacktrace ) { + $html .= '

Backtrace:

' .
+				htmlspecialchars( $e->getTraceAsString() ) . '
'; + } + + $html .= '
'; + $html .= self::googleSearchForm(); + + echo $html; + } + + /** + * @return string + */ + private static function googleSearchForm() { + global $wgSitename, $wgCanonicalServer, $wgRequest; + + $usegoogle = htmlspecialchars( self::msg( + 'dberr-usegoogle', + 'You can try searching via Google in the meantime.' + ) ); + $outofdate = htmlspecialchars( self::msg( + 'dberr-outofdate', + 'Note that their indexes of our content may be out of date.' + ) ); + $googlesearch = htmlspecialchars( self::msg( 'searchbutton', 'Search' ) ); + $search = htmlspecialchars( $wgRequest->getVal( 'search' ) ); + $server = htmlspecialchars( $wgCanonicalServer ); + $sitename = htmlspecialchars( $wgSitename ); + $trygoogle = <<$usegoogle
+$outofdate + +
+ + + + + + +

+ + +

+
+EOT; + return $trygoogle; + } +} diff --git a/includes/exception/PermissionsError.php b/includes/exception/PermissionsError.php index bd0b1204d4..e31374c2c7 100644 --- a/includes/exception/PermissionsError.php +++ b/includes/exception/PermissionsError.php @@ -29,12 +29,19 @@ class PermissionsError extends ErrorPageError { public $permission, $errors; /** - * @param string $permission A permission name. - * @param string[] $errors Error message keys + * @param string|null $permission A permission name or null if unknown + * @param array $errors Error message keys or [key, param...] arrays; must not be empty if + * $permission is null + * @throws \InvalidArgumentException */ public function __construct( $permission, $errors = [] ) { global $wgLang; + if ( $permission === null && !$errors ) { + throw new \InvalidArgumentException( __METHOD__ . + ': $permission and $errors cannot both be empty' ); + } + $this->permission = $permission; if ( !count( $errors ) ) { diff --git a/includes/exception/TimestampException.php b/includes/exception/TimestampException.php deleted file mode 100644 index b9c0c35c71..0000000000 --- a/includes/exception/TimestampException.php +++ /dev/null @@ -1,7 +0,0 @@ -msg, LoginForm::getValidErrorMessages() ) ) { + if ( !in_array( $this->msg, LoginHelper::getValidErrorMessages() ) ) { parent::report(); } diff --git a/includes/export/DumpStringOutput.php b/includes/export/DumpStringOutput.php new file mode 100644 index 0000000000..837a62d628 --- /dev/null +++ b/includes/export/DumpStringOutput.php @@ -0,0 +1,45 @@ +output .= $string; + } + + /** + * Get the string containing the output. + * + * @return string + */ + public function __toString() { + return $this->output; + } +} diff --git a/includes/export/WikiExporter.php b/includes/export/WikiExporter.php index 54de26d4dd..c1f2d59dcc 100644 --- a/includes/export/WikiExporter.php +++ b/includes/export/WikiExporter.php @@ -134,13 +134,21 @@ class WikiExporter { * @param int $start Inclusive lower limit (this id is included) * @param int $end Exclusive upper limit (this id is not included) * If 0, no upper limit. + * @param bool $orderRevs order revisions within pages in ascending order */ - public function pagesByRange( $start, $end ) { - $condition = 'page_id >= ' . intval( $start ); - if ( $end ) { - $condition .= ' AND page_id < ' . intval( $end ); + public function pagesByRange( $start, $end, $orderRevs ) { + if ( $orderRevs ) { + $condition = 'rev_page >= ' . intval( $start ); + if ( $end ) { + $condition .= ' AND rev_page < ' . intval( $end ); + } + } else { + $condition = 'page_id >= ' . intval( $start ); + if ( $end ) { + $condition .= ' AND page_id < ' . intval( $end ); + } } - $this->dumpFrom( $condition ); + $this->dumpFrom( $condition, $orderRevs ); } /** @@ -245,7 +253,7 @@ class WikiExporter { * @throws MWException * @throws Exception */ - protected function dumpFrom( $cond = '' ) { + protected function dumpFrom( $cond = '', $orderRevs = false ) { # For logging dumps... if ( $this->history & self::LOGS ) { $where = [ 'user_id = log_user' ]; @@ -332,7 +340,16 @@ class WikiExporter { } } elseif ( $this->history & WikiExporter::FULL ) { # Full history dumps... - $join['revision'] = [ 'INNER JOIN', 'page_id=rev_page' ]; + # query optimization for history stub dumps + if ( $this->text == WikiExporter::STUB && $orderRevs ) { + $tables = [ 'revision', 'page' ]; + $opts[] = 'STRAIGHT_JOIN'; + $opts['ORDER BY'] = [ 'rev_page ASC', 'rev_id ASC' ]; + $opts['USE INDEX']['revision'] = 'rev_page_id'; + $join['page'] = [ 'INNER JOIN', 'rev_page=page_id' ]; + } else { + $join['revision'] = [ 'INNER JOIN', 'page_id=rev_page' ]; + } } elseif ( $this->history & WikiExporter::CURRENT ) { # Latest revision dumps... if ( $this->list_authors && $cond != '' ) { // List authors, if so desired @@ -369,7 +386,6 @@ class WikiExporter { if ( $this->buffer == WikiExporter::STREAM ) { $prev = $this->db->bufferResults( false ); } - $result = null; // Assuring $result is not undefined, if exception occurs early try { Hooks::run( 'ModifyExportQuery', diff --git a/includes/export/XmlDumpWriter.php b/includes/export/XmlDumpWriter.php index 42168d7603..ab268032a6 100644 --- a/includes/export/XmlDumpWriter.php +++ b/includes/export/XmlDumpWriter.php @@ -51,7 +51,7 @@ class XmlDumpWriter { * you copy in the new xsd file. * * After it is reviewed, merged and deployed (sync-docroot), the index.html needs purging. - * echo "http://www.mediawiki.org/xml/index.html" | mwscript purgeList.php --wiki=aawiki + * echo "https://www.mediawiki.org/xml/index.html" | mwscript purgeList.php --wiki=aawiki */ 'xsi:schemaLocation' => "http://www.mediawiki.org/xml/export-$ver/ " . "http://www.mediawiki.org/xml/export-$ver.xsd", diff --git a/includes/externalstore/ExternalStoreDB.php b/includes/externalstore/ExternalStoreDB.php index b45457720e..7e932994e6 100644 --- a/includes/externalstore/ExternalStoreDB.php +++ b/includes/externalstore/ExternalStoreDB.php @@ -112,7 +112,7 @@ class ExternalStoreDB extends ExternalStoreMedium { } /** - * Get a slave database connection for the specified cluster + * Get a replica DB connection for the specified cluster * * @param string $cluster Cluster name * @return IDatabase @@ -130,7 +130,7 @@ class ExternalStoreDB extends ExternalStoreMedium { wfDebug( "writable external store\n" ); } - $db = $lb->getConnection( DB_SLAVE, [], $wiki ); + $db = $lb->getConnectionRef( DB_REPLICA, [], $wiki ); $db->clearFlag( DBO_TRX ); // sanity return $db; @@ -146,7 +146,7 @@ class ExternalStoreDB extends ExternalStoreMedium { $wiki = isset( $this->params['wiki'] ) ? $this->params['wiki'] : false; $lb = $this->getLoadBalancer( $cluster ); - $db = $lb->getConnection( DB_MASTER, [], $wiki ); + $db = $lb->getConnectionRef( DB_MASTER, [], $wiki ); $db->clearFlag( DBO_TRX ); // sanity return $db; @@ -264,7 +264,7 @@ class ExternalStoreDB extends ExternalStoreMedium { } /** - * Helper function for self::batchFetchBlobs for merging master/slave results + * Helper function for self::batchFetchBlobs for merging master/replica DB results * @param array &$ret Current self::batchFetchBlobs return value * @param array &$ids Map from blob_id to requested itemIDs * @param mixed $res DB result from Database::select diff --git a/includes/filebackend/FSFile.php b/includes/filebackend/FSFile.php deleted file mode 100644 index 8aa11b6565..0000000000 --- a/includes/filebackend/FSFile.php +++ /dev/null @@ -1,280 +0,0 @@ -path = $path; - } - - /** - * Returns the file system path - * - * @return string - */ - public function getPath() { - return $this->path; - } - - /** - * Checks if the file exists - * - * @return bool - */ - public function exists() { - return is_file( $this->path ); - } - - /** - * Get the file size in bytes - * - * @return int|bool - */ - public function getSize() { - return filesize( $this->path ); - } - - /** - * Get the file's last-modified timestamp - * - * @return string|bool TS_MW timestamp or false on failure - */ - public function getTimestamp() { - MediaWiki\suppressWarnings(); - $timestamp = filemtime( $this->path ); - MediaWiki\restoreWarnings(); - if ( $timestamp !== false ) { - $timestamp = wfTimestamp( TS_MW, $timestamp ); - } - - return $timestamp; - } - - /** - * Guess the MIME type from the file contents alone - * - * @return string - */ - public function getMimeType() { - return MimeMagic::singleton()->guessMimeType( $this->path, false ); - } - - /** - * Get an associative array containing information about - * a file with the given storage path. - * - * Resulting array fields include: - * - fileExists - * - size (filesize in bytes) - * - mime (as major/minor) - * - media_type (value to be used with the MEDIATYPE_xxx constants) - * - metadata (handler specific) - * - sha1 (in base 36) - * - width - * - height - * - bits (bitrate) - * - file-mime - * - major_mime - * - minor_mime - * - * @param string|bool $ext The file extension, or true to extract it from the filename. - * Set it to false to ignore the extension. - * @return array - */ - public function getProps( $ext = true ) { - wfDebug( __METHOD__ . ": Getting file info for $this->path\n" ); - - $info = self::placeholderProps(); - $info['fileExists'] = $this->exists(); - - if ( $info['fileExists'] ) { - $magic = MimeMagic::singleton(); - - # get the file extension - if ( $ext === true ) { - $ext = self::extensionFromPath( $this->path ); - } - - # MIME type according to file contents - $info['file-mime'] = $this->getMimeType(); - # logical MIME type - $info['mime'] = $magic->improveTypeFromExtension( $info['file-mime'], $ext ); - - list( $info['major_mime'], $info['minor_mime'] ) = File::splitMime( $info['mime'] ); - $info['media_type'] = $magic->getMediaType( $this->path, $info['mime'] ); - - # Get size in bytes - $info['size'] = $this->getSize(); - - # Height, width and metadata - $handler = MediaHandler::getHandler( $info['mime'] ); - if ( $handler ) { - $tempImage = (object)[]; // XXX (hack for File object) - $info['metadata'] = $handler->getMetadata( $tempImage, $this->path ); - $gis = $handler->getImageSize( $tempImage, $this->path, $info['metadata'] ); - if ( is_array( $gis ) ) { - $info = $this->extractImageSizeInfo( $gis ) + $info; - } - } - $info['sha1'] = $this->getSha1Base36(); - - wfDebug( __METHOD__ . ": $this->path loaded, {$info['size']} bytes, {$info['mime']}.\n" ); - } else { - wfDebug( __METHOD__ . ": $this->path NOT FOUND!\n" ); - } - - return $info; - } - - /** - * Placeholder file properties to use for files that don't exist - * - * Resulting array fields include: - * - fileExists - * - mime (as major/minor) - * - media_type (value to be used with the MEDIATYPE_xxx constants) - * - metadata (handler specific) - * - sha1 (in base 36) - * - width - * - height - * - bits (bitrate) - * - * @return array - */ - public static function placeholderProps() { - $info = []; - $info['fileExists'] = false; - $info['mime'] = null; - $info['media_type'] = MEDIATYPE_UNKNOWN; - $info['metadata'] = ''; - $info['sha1'] = ''; - $info['width'] = 0; - $info['height'] = 0; - $info['bits'] = 0; - - return $info; - } - - /** - * Exract image size information - * - * @param array $gis - * @return array - */ - protected function extractImageSizeInfo( array $gis ) { - $info = []; - # NOTE: $gis[2] contains a code for the image type. This is no longer used. - $info['width'] = $gis[0]; - $info['height'] = $gis[1]; - if ( isset( $gis['bits'] ) ) { - $info['bits'] = $gis['bits']; - } else { - $info['bits'] = 0; - } - - return $info; - } - - /** - * Get a SHA-1 hash of a file in the local filesystem, in base-36 lower case - * encoding, zero padded to 31 digits. - * - * 160 log 2 / log 36 = 30.95, so the 160-bit hash fills 31 digits in base 36 - * fairly neatly. - * - * @param bool $recache - * @return bool|string False on failure - */ - public function getSha1Base36( $recache = false ) { - if ( $this->sha1Base36 !== null && !$recache ) { - return $this->sha1Base36; - } - - MediaWiki\suppressWarnings(); - $this->sha1Base36 = sha1_file( $this->path ); - MediaWiki\restoreWarnings(); - - if ( $this->sha1Base36 !== false ) { - $this->sha1Base36 = Wikimedia\base_convert( $this->sha1Base36, 16, 36, 31 ); - } - - return $this->sha1Base36; - } - - /** - * Get the final file extension from a file system path - * - * @param string $path - * @return string - */ - public static function extensionFromPath( $path ) { - $i = strrpos( $path, '.' ); - - return strtolower( $i ? substr( $path, $i + 1 ) : '' ); - } - - /** - * Get an associative array containing information about a file in the local filesystem. - * - * @param string $path Absolute local filesystem path - * @param string|bool $ext The file extension, or true to extract it from the filename. - * Set it to false to ignore the extension. - * @return array - */ - public static function getPropsFromPath( $path, $ext = true ) { - $fsFile = new self( $path ); - - return $fsFile->getProps( $ext ); - } - - /** - * Get a SHA-1 hash of a file in the local filesystem, in base-36 lower case - * encoding, zero padded to 31 digits. - * - * 160 log 2 / log 36 = 30.95, so the 160-bit hash fills 31 digits in base 36 - * fairly neatly. - * - * @param string $path - * @return bool|string False on failure - */ - public static function getSha1Base36FromPath( $path ) { - $fsFile = new self( $path ); - - return $fsFile->getSha1Base36(); - } -} diff --git a/includes/filebackend/FSFileBackend.php b/includes/filebackend/FSFileBackend.php deleted file mode 100644 index efe78ee24b..0000000000 --- a/includes/filebackend/FSFileBackend.php +++ /dev/null @@ -1,975 +0,0 @@ -basePath = rtrim( $config['basePath'], '/' ); // remove trailing slash - } else { - $this->basePath = null; // none; containers must have explicit paths - } - - if ( isset( $config['containerPaths'] ) ) { - $this->containerPaths = (array)$config['containerPaths']; - foreach ( $this->containerPaths as &$path ) { - $path = rtrim( $path, '/' ); // remove trailing slash - } - } - - $this->fileMode = isset( $config['fileMode'] ) ? $config['fileMode'] : 0644; - if ( isset( $config['fileOwner'] ) && function_exists( 'posix_getuid' ) ) { - $this->fileOwner = $config['fileOwner']; - // cache this, assuming it doesn't change - $this->currentUser = posix_getpwuid( posix_getuid() )['name']; - } - } - - public function getFeatures() { - return !wfIsWindows() ? FileBackend::ATTR_UNICODE_PATHS : 0; - } - - protected function resolveContainerPath( $container, $relStoragePath ) { - // Check that container has a root directory - if ( isset( $this->containerPaths[$container] ) || isset( $this->basePath ) ) { - // Check for sane relative paths (assume the base paths are OK) - if ( $this->isLegalRelPath( $relStoragePath ) ) { - return $relStoragePath; - } - } - - return null; - } - - /** - * Sanity check a relative file system path for validity - * - * @param string $path Normalized relative path - * @return bool - */ - protected function isLegalRelPath( $path ) { - // Check for file names longer than 255 chars - if ( preg_match( '![^/]{256}!', $path ) ) { // ext3/NTFS - return false; - } - if ( wfIsWindows() ) { // NTFS - return !preg_match( '![:*?"<>|]!', $path ); - } else { - return true; - } - } - - /** - * Given the short (unresolved) and full (resolved) name of - * a container, return the file system path of the container. - * - * @param string $shortCont - * @param string $fullCont - * @return string|null - */ - protected function containerFSRoot( $shortCont, $fullCont ) { - if ( isset( $this->containerPaths[$shortCont] ) ) { - return $this->containerPaths[$shortCont]; - } elseif ( isset( $this->basePath ) ) { - return "{$this->basePath}/{$fullCont}"; - } - - return null; // no container base path defined - } - - /** - * Get the absolute file system path for a storage path - * - * @param string $storagePath Storage path - * @return string|null - */ - protected function resolveToFSPath( $storagePath ) { - list( $fullCont, $relPath ) = $this->resolveStoragePathReal( $storagePath ); - if ( $relPath === null ) { - return null; // invalid - } - list( , $shortCont, ) = FileBackend::splitStoragePath( $storagePath ); - $fsPath = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid - if ( $relPath != '' ) { - $fsPath .= "/{$relPath}"; - } - - return $fsPath; - } - - public function isPathUsableInternal( $storagePath ) { - $fsPath = $this->resolveToFSPath( $storagePath ); - if ( $fsPath === null ) { - return false; // invalid - } - $parentDir = dirname( $fsPath ); - - if ( file_exists( $fsPath ) ) { - $ok = is_file( $fsPath ) && is_writable( $fsPath ); - } else { - $ok = is_dir( $parentDir ) && is_writable( $parentDir ); - } - - if ( $this->fileOwner !== null && $this->currentUser !== $this->fileOwner ) { - $ok = false; - trigger_error( __METHOD__ . ": PHP process owner is not '{$this->fileOwner}'." ); - } - - return $ok; - } - - protected function doCreateInternal( array $params ) { - $status = Status::newGood(); - - $dest = $this->resolveToFSPath( $params['dst'] ); - if ( $dest === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['dst'] ); - - return $status; - } - - if ( !empty( $params['async'] ) ) { // deferred - $tempFile = TempFSFile::factory( 'create_', 'tmp' ); - if ( !$tempFile ) { - $status->fatal( 'backend-fail-create', $params['dst'] ); - - return $status; - } - $this->trapWarnings(); - $bytes = file_put_contents( $tempFile->getPath(), $params['content'] ); - $this->untrapWarnings(); - if ( $bytes === false ) { - $status->fatal( 'backend-fail-create', $params['dst'] ); - - return $status; - } - $cmd = implode( ' ', [ - wfIsWindows() ? 'COPY /B /Y' : 'cp', // (binary, overwrite) - wfEscapeShellArg( $this->cleanPathSlashes( $tempFile->getPath() ) ), - wfEscapeShellArg( $this->cleanPathSlashes( $dest ) ) - ] ); - $handler = function ( $errors, Status $status, array $params, $cmd ) { - if ( $errors !== '' && !( wfIsWindows() && $errors[0] === " " ) ) { - $status->fatal( 'backend-fail-create', $params['dst'] ); - trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output - } - }; - $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd, $dest ); - $tempFile->bind( $status->value ); - } else { // immediate write - $this->trapWarnings(); - $bytes = file_put_contents( $dest, $params['content'] ); - $this->untrapWarnings(); - if ( $bytes === false ) { - $status->fatal( 'backend-fail-create', $params['dst'] ); - - return $status; - } - $this->chmod( $dest ); - } - - return $status; - } - - protected function doStoreInternal( array $params ) { - $status = Status::newGood(); - - $dest = $this->resolveToFSPath( $params['dst'] ); - if ( $dest === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['dst'] ); - - return $status; - } - - if ( !empty( $params['async'] ) ) { // deferred - $cmd = implode( ' ', [ - wfIsWindows() ? 'COPY /B /Y' : 'cp', // (binary, overwrite) - wfEscapeShellArg( $this->cleanPathSlashes( $params['src'] ) ), - wfEscapeShellArg( $this->cleanPathSlashes( $dest ) ) - ] ); - $handler = function ( $errors, Status $status, array $params, $cmd ) { - if ( $errors !== '' && !( wfIsWindows() && $errors[0] === " " ) ) { - $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] ); - trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output - } - }; - $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd, $dest ); - } else { // immediate write - $this->trapWarnings(); - $ok = copy( $params['src'], $dest ); - $this->untrapWarnings(); - // In some cases (at least over NFS), copy() returns true when it fails - if ( !$ok || ( filesize( $params['src'] ) !== filesize( $dest ) ) ) { - if ( $ok ) { // PHP bug - unlink( $dest ); // remove broken file - trigger_error( __METHOD__ . ": copy() failed but returned true." ); - } - $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] ); - - return $status; - } - $this->chmod( $dest ); - } - - return $status; - } - - protected function doCopyInternal( array $params ) { - $status = Status::newGood(); - - $source = $this->resolveToFSPath( $params['src'] ); - if ( $source === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['src'] ); - - return $status; - } - - $dest = $this->resolveToFSPath( $params['dst'] ); - if ( $dest === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['dst'] ); - - return $status; - } - - if ( !is_file( $source ) ) { - if ( empty( $params['ignoreMissingSource'] ) ) { - $status->fatal( 'backend-fail-copy', $params['src'] ); - } - - return $status; // do nothing; either OK or bad status - } - - if ( !empty( $params['async'] ) ) { // deferred - $cmd = implode( ' ', [ - wfIsWindows() ? 'COPY /B /Y' : 'cp', // (binary, overwrite) - wfEscapeShellArg( $this->cleanPathSlashes( $source ) ), - wfEscapeShellArg( $this->cleanPathSlashes( $dest ) ) - ] ); - $handler = function ( $errors, Status $status, array $params, $cmd ) { - if ( $errors !== '' && !( wfIsWindows() && $errors[0] === " " ) ) { - $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] ); - trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output - } - }; - $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd, $dest ); - } else { // immediate write - $this->trapWarnings(); - $ok = ( $source === $dest ) ? true : copy( $source, $dest ); - $this->untrapWarnings(); - // In some cases (at least over NFS), copy() returns true when it fails - if ( !$ok || ( filesize( $source ) !== filesize( $dest ) ) ) { - if ( $ok ) { // PHP bug - $this->trapWarnings(); - unlink( $dest ); // remove broken file - $this->untrapWarnings(); - trigger_error( __METHOD__ . ": copy() failed but returned true." ); - } - $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] ); - - return $status; - } - $this->chmod( $dest ); - } - - return $status; - } - - protected function doMoveInternal( array $params ) { - $status = Status::newGood(); - - $source = $this->resolveToFSPath( $params['src'] ); - if ( $source === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['src'] ); - - return $status; - } - - $dest = $this->resolveToFSPath( $params['dst'] ); - if ( $dest === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['dst'] ); - - return $status; - } - - if ( !is_file( $source ) ) { - if ( empty( $params['ignoreMissingSource'] ) ) { - $status->fatal( 'backend-fail-move', $params['src'] ); - } - - return $status; // do nothing; either OK or bad status - } - - if ( !empty( $params['async'] ) ) { // deferred - $cmd = implode( ' ', [ - wfIsWindows() ? 'MOVE /Y' : 'mv', // (overwrite) - wfEscapeShellArg( $this->cleanPathSlashes( $source ) ), - wfEscapeShellArg( $this->cleanPathSlashes( $dest ) ) - ] ); - $handler = function ( $errors, Status $status, array $params, $cmd ) { - if ( $errors !== '' && !( wfIsWindows() && $errors[0] === " " ) ) { - $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] ); - trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output - } - }; - $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd ); - } else { // immediate write - $this->trapWarnings(); - $ok = ( $source === $dest ) ? true : rename( $source, $dest ); - $this->untrapWarnings(); - clearstatcache(); // file no longer at source - if ( !$ok ) { - $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] ); - - return $status; - } - } - - return $status; - } - - protected function doDeleteInternal( array $params ) { - $status = Status::newGood(); - - $source = $this->resolveToFSPath( $params['src'] ); - if ( $source === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['src'] ); - - return $status; - } - - if ( !is_file( $source ) ) { - if ( empty( $params['ignoreMissingSource'] ) ) { - $status->fatal( 'backend-fail-delete', $params['src'] ); - } - - return $status; // do nothing; either OK or bad status - } - - if ( !empty( $params['async'] ) ) { // deferred - $cmd = implode( ' ', [ - wfIsWindows() ? 'DEL' : 'unlink', - wfEscapeShellArg( $this->cleanPathSlashes( $source ) ) - ] ); - $handler = function ( $errors, Status $status, array $params, $cmd ) { - if ( $errors !== '' && !( wfIsWindows() && $errors[0] === " " ) ) { - $status->fatal( 'backend-fail-delete', $params['src'] ); - trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output - } - }; - $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd ); - } else { // immediate write - $this->trapWarnings(); - $ok = unlink( $source ); - $this->untrapWarnings(); - if ( !$ok ) { - $status->fatal( 'backend-fail-delete', $params['src'] ); - - return $status; - } - } - - return $status; - } - - /** - * @param string $fullCont - * @param string $dirRel - * @param array $params - * @return Status - */ - protected function doPrepareInternal( $fullCont, $dirRel, array $params ) { - $status = Status::newGood(); - list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] ); - $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid - $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot; - $existed = is_dir( $dir ); // already there? - // Create the directory and its parents as needed... - $this->trapWarnings(); - if ( !wfMkdirParents( $dir ) ) { - wfDebugLog( 'FSFileBackend', __METHOD__ . ": cannot create directory $dir" ); - $status->fatal( 'directorycreateerror', $params['dir'] ); // fails on races - } elseif ( !is_writable( $dir ) ) { - wfDebugLog( 'FSFileBackend', __METHOD__ . ": directory $dir is read-only" ); - $status->fatal( 'directoryreadonlyerror', $params['dir'] ); - } elseif ( !is_readable( $dir ) ) { - wfDebugLog( 'FSFileBackend', __METHOD__ . ": directory $dir is not readable" ); - $status->fatal( 'directorynotreadableerror', $params['dir'] ); - } - $this->untrapWarnings(); - // Respect any 'noAccess' or 'noListing' flags... - if ( is_dir( $dir ) && !$existed ) { - $status->merge( $this->doSecureInternal( $fullCont, $dirRel, $params ) ); - } - - return $status; - } - - protected function doSecureInternal( $fullCont, $dirRel, array $params ) { - $status = Status::newGood(); - list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] ); - $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid - $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot; - // Seed new directories with a blank index.html, to prevent crawling... - if ( !empty( $params['noListing'] ) && !file_exists( "{$dir}/index.html" ) ) { - $this->trapWarnings(); - $bytes = file_put_contents( "{$dir}/index.html", $this->indexHtmlPrivate() ); - $this->untrapWarnings(); - if ( $bytes === false ) { - $status->fatal( 'backend-fail-create', $params['dir'] . '/index.html' ); - } - } - // Add a .htaccess file to the root of the container... - if ( !empty( $params['noAccess'] ) && !file_exists( "{$contRoot}/.htaccess" ) ) { - $this->trapWarnings(); - $bytes = file_put_contents( "{$contRoot}/.htaccess", $this->htaccessPrivate() ); - $this->untrapWarnings(); - if ( $bytes === false ) { - $storeDir = "mwstore://{$this->name}/{$shortCont}"; - $status->fatal( 'backend-fail-create', "{$storeDir}/.htaccess" ); - } - } - - return $status; - } - - protected function doPublishInternal( $fullCont, $dirRel, array $params ) { - $status = Status::newGood(); - list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] ); - $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid - $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot; - // Unseed new directories with a blank index.html, to allow crawling... - if ( !empty( $params['listing'] ) && is_file( "{$dir}/index.html" ) ) { - $exists = ( file_get_contents( "{$dir}/index.html" ) === $this->indexHtmlPrivate() ); - $this->trapWarnings(); - if ( $exists && !unlink( "{$dir}/index.html" ) ) { // reverse secure() - $status->fatal( 'backend-fail-delete', $params['dir'] . '/index.html' ); - } - $this->untrapWarnings(); - } - // Remove the .htaccess file from the root of the container... - if ( !empty( $params['access'] ) && is_file( "{$contRoot}/.htaccess" ) ) { - $exists = ( file_get_contents( "{$contRoot}/.htaccess" ) === $this->htaccessPrivate() ); - $this->trapWarnings(); - if ( $exists && !unlink( "{$contRoot}/.htaccess" ) ) { // reverse secure() - $storeDir = "mwstore://{$this->name}/{$shortCont}"; - $status->fatal( 'backend-fail-delete', "{$storeDir}/.htaccess" ); - } - $this->untrapWarnings(); - } - - return $status; - } - - protected function doCleanInternal( $fullCont, $dirRel, array $params ) { - $status = Status::newGood(); - list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] ); - $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid - $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot; - $this->trapWarnings(); - if ( is_dir( $dir ) ) { - rmdir( $dir ); // remove directory if empty - } - $this->untrapWarnings(); - - return $status; - } - - protected function doGetFileStat( array $params ) { - $source = $this->resolveToFSPath( $params['src'] ); - if ( $source === null ) { - return false; // invalid storage path - } - - $this->trapWarnings(); // don't trust 'false' if there were errors - $stat = is_file( $source ) ? stat( $source ) : false; // regular files only - $hadError = $this->untrapWarnings(); - - if ( $stat ) { - return [ - 'mtime' => wfTimestamp( TS_MW, $stat['mtime'] ), - 'size' => $stat['size'] - ]; - } elseif ( !$hadError ) { - return false; // file does not exist - } else { - return null; // failure - } - } - - protected function doClearCache( array $paths = null ) { - clearstatcache(); // clear the PHP file stat cache - } - - protected function doDirectoryExists( $fullCont, $dirRel, array $params ) { - list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] ); - $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid - $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot; - - $this->trapWarnings(); // don't trust 'false' if there were errors - $exists = is_dir( $dir ); - $hadError = $this->untrapWarnings(); - - return $hadError ? null : $exists; - } - - /** - * @see FileBackendStore::getDirectoryListInternal() - * @param string $fullCont - * @param string $dirRel - * @param array $params - * @return array|null - */ - public function getDirectoryListInternal( $fullCont, $dirRel, array $params ) { - list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] ); - $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid - $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot; - $exists = is_dir( $dir ); - if ( !$exists ) { - wfDebug( __METHOD__ . "() given directory does not exist: '$dir'\n" ); - - return []; // nothing under this dir - } elseif ( !is_readable( $dir ) ) { - wfDebug( __METHOD__ . "() given directory is unreadable: '$dir'\n" ); - - return null; // bad permissions? - } - - return new FSFileBackendDirList( $dir, $params ); - } - - /** - * @see FileBackendStore::getFileListInternal() - * @param string $fullCont - * @param string $dirRel - * @param array $params - * @return array|FSFileBackendFileList|null - */ - public function getFileListInternal( $fullCont, $dirRel, array $params ) { - list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] ); - $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid - $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot; - $exists = is_dir( $dir ); - if ( !$exists ) { - wfDebug( __METHOD__ . "() given directory does not exist: '$dir'\n" ); - - return []; // nothing under this dir - } elseif ( !is_readable( $dir ) ) { - wfDebug( __METHOD__ . "() given directory is unreadable: '$dir'\n" ); - - return null; // bad permissions? - } - - return new FSFileBackendFileList( $dir, $params ); - } - - protected function doGetLocalReferenceMulti( array $params ) { - $fsFiles = []; // (path => FSFile) - - foreach ( $params['srcs'] as $src ) { - $source = $this->resolveToFSPath( $src ); - if ( $source === null || !is_file( $source ) ) { - $fsFiles[$src] = null; // invalid path or file does not exist - } else { - $fsFiles[$src] = new FSFile( $source ); - } - } - - return $fsFiles; - } - - protected function doGetLocalCopyMulti( array $params ) { - $tmpFiles = []; // (path => TempFSFile) - - foreach ( $params['srcs'] as $src ) { - $source = $this->resolveToFSPath( $src ); - if ( $source === null ) { - $tmpFiles[$src] = null; // invalid path - } else { - // Create a new temporary file with the same extension... - $ext = FileBackend::extensionFromPath( $src ); - $tmpFile = TempFSFile::factory( 'localcopy_', $ext ); - if ( !$tmpFile ) { - $tmpFiles[$src] = null; - } else { - $tmpPath = $tmpFile->getPath(); - // Copy the source file over the temp file - $this->trapWarnings(); - $ok = copy( $source, $tmpPath ); - $this->untrapWarnings(); - if ( !$ok ) { - $tmpFiles[$src] = null; - } else { - $this->chmod( $tmpPath ); - $tmpFiles[$src] = $tmpFile; - } - } - } - } - - return $tmpFiles; - } - - protected function directoriesAreVirtual() { - return false; - } - - /** - * @param FSFileOpHandle[] $fileOpHandles - * - * @return Status[] - */ - protected function doExecuteOpHandlesInternal( array $fileOpHandles ) { - $statuses = []; - - $pipes = []; - foreach ( $fileOpHandles as $index => $fileOpHandle ) { - $pipes[$index] = popen( "{$fileOpHandle->cmd} 2>&1", 'r' ); - } - - $errs = []; - foreach ( $pipes as $index => $pipe ) { - // Result will be empty on success in *NIX. On Windows, - // it may be something like " 1 file(s) [copied|moved].". - $errs[$index] = stream_get_contents( $pipe ); - fclose( $pipe ); - } - - foreach ( $fileOpHandles as $index => $fileOpHandle ) { - $status = Status::newGood(); - $function = $fileOpHandle->call; - $function( $errs[$index], $status, $fileOpHandle->params, $fileOpHandle->cmd ); - $statuses[$index] = $status; - if ( $status->isOK() && $fileOpHandle->chmodPath ) { - $this->chmod( $fileOpHandle->chmodPath ); - } - } - - clearstatcache(); // files changed - return $statuses; - } - - /** - * Chmod a file, suppressing the warnings - * - * @param string $path Absolute file system path - * @return bool Success - */ - protected function chmod( $path ) { - $this->trapWarnings(); - $ok = chmod( $path, $this->fileMode ); - $this->untrapWarnings(); - - return $ok; - } - - /** - * Return the text of an index.html file to hide directory listings - * - * @return string - */ - protected function indexHtmlPrivate() { - return ''; - } - - /** - * Return the text of a .htaccess file to make a directory private - * - * @return string - */ - protected function htaccessPrivate() { - return "Deny from all\n"; - } - - /** - * Clean up directory separators for the given OS - * - * @param string $path FS path - * @return string - */ - protected function cleanPathSlashes( $path ) { - return wfIsWindows() ? strtr( $path, '/', '\\' ) : $path; - } - - /** - * Listen for E_WARNING errors and track whether any happen - */ - protected function trapWarnings() { - $this->hadWarningErrors[] = false; // push to stack - set_error_handler( [ $this, 'handleWarning' ], E_WARNING ); - } - - /** - * Stop listening for E_WARNING errors and return true if any happened - * - * @return bool - */ - protected function untrapWarnings() { - restore_error_handler(); // restore previous handler - return array_pop( $this->hadWarningErrors ); // pop from stack - } - - /** - * @param int $errno - * @param string $errstr - * @return bool - * @access private - */ - public function handleWarning( $errno, $errstr ) { - wfDebugLog( 'FSFileBackend', $errstr ); // more detailed error logging - $this->hadWarningErrors[count( $this->hadWarningErrors ) - 1] = true; - - return true; // suppress from PHP handler - } -} - -/** - * @see FileBackendStoreOpHandle - */ -class FSFileOpHandle extends FileBackendStoreOpHandle { - public $cmd; // string; shell command - public $chmodPath; // string; file to chmod - - /** - * @param FSFileBackend $backend - * @param array $params - * @param callable $call - * @param string $cmd - * @param int|null $chmodPath - */ - public function __construct( - FSFileBackend $backend, array $params, $call, $cmd, $chmodPath = null - ) { - $this->backend = $backend; - $this->params = $params; - $this->call = $call; - $this->cmd = $cmd; - $this->chmodPath = $chmodPath; - } -} - -/** - * Wrapper around RecursiveDirectoryIterator/DirectoryIterator that - * catches exception or does any custom behavoir that we may want. - * Do not use this class from places outside FSFileBackend. - * - * @ingroup FileBackend - */ -abstract class FSFileBackendList implements Iterator { - /** @var Iterator */ - protected $iter; - - /** @var int */ - protected $suffixStart; - - /** @var int */ - protected $pos = 0; - - /** @var array */ - protected $params = []; - - /** - * @param string $dir File system directory - * @param array $params - */ - public function __construct( $dir, array $params ) { - $path = realpath( $dir ); // normalize - if ( $path === false ) { - $path = $dir; - } - $this->suffixStart = strlen( $path ) + 1; // size of "path/to/dir/" - $this->params = $params; - - try { - $this->iter = $this->initIterator( $path ); - } catch ( UnexpectedValueException $e ) { - $this->iter = null; // bad permissions? deleted? - } - } - - /** - * Return an appropriate iterator object to wrap - * - * @param string $dir File system directory - * @return Iterator - */ - protected function initIterator( $dir ) { - if ( !empty( $this->params['topOnly'] ) ) { // non-recursive - # Get an iterator that will get direct sub-nodes - return new DirectoryIterator( $dir ); - } else { // recursive - # Get an iterator that will return leaf nodes (non-directories) - # RecursiveDirectoryIterator extends FilesystemIterator. - # FilesystemIterator::SKIP_DOTS default is inconsistent in PHP 5.3.x. - $flags = FilesystemIterator::CURRENT_AS_SELF | FilesystemIterator::SKIP_DOTS; - - return new RecursiveIteratorIterator( - new RecursiveDirectoryIterator( $dir, $flags ), - RecursiveIteratorIterator::CHILD_FIRST // include dirs - ); - } - } - - /** - * @see Iterator::key() - * @return int - */ - public function key() { - return $this->pos; - } - - /** - * @see Iterator::current() - * @return string|bool String or false - */ - public function current() { - return $this->getRelPath( $this->iter->current()->getPathname() ); - } - - /** - * @see Iterator::next() - * @throws FileBackendError - */ - public function next() { - try { - $this->iter->next(); - $this->filterViaNext(); - } catch ( UnexpectedValueException $e ) { // bad permissions? deleted? - throw new FileBackendError( "File iterator gave UnexpectedValueException." ); - } - ++$this->pos; - } - - /** - * @see Iterator::rewind() - * @throws FileBackendError - */ - public function rewind() { - $this->pos = 0; - try { - $this->iter->rewind(); - $this->filterViaNext(); - } catch ( UnexpectedValueException $e ) { // bad permissions? deleted? - throw new FileBackendError( "File iterator gave UnexpectedValueException." ); - } - } - - /** - * @see Iterator::valid() - * @return bool - */ - public function valid() { - return $this->iter && $this->iter->valid(); - } - - /** - * Filter out items by advancing to the next ones - */ - protected function filterViaNext() { - } - - /** - * Return only the relative path and normalize slashes to FileBackend-style. - * Uses the "real path" since the suffix is based upon that. - * - * @param string $dir - * @return string - */ - protected function getRelPath( $dir ) { - $path = realpath( $dir ); - if ( $path === false ) { - $path = $dir; - } - - return strtr( substr( $path, $this->suffixStart ), '\\', '/' ); - } -} - -class FSFileBackendDirList extends FSFileBackendList { - protected function filterViaNext() { - while ( $this->iter->valid() ) { - if ( $this->iter->current()->isDot() || !$this->iter->current()->isDir() ) { - $this->iter->next(); // skip non-directories and dot files - } else { - break; - } - } - } -} - -class FSFileBackendFileList extends FSFileBackendList { - protected function filterViaNext() { - while ( $this->iter->valid() ) { - if ( !$this->iter->current()->isFile() ) { - $this->iter->next(); // skip non-files and dot files - } else { - break; - } - } - } -} diff --git a/includes/filebackend/FileBackend.php b/includes/filebackend/FileBackend.php deleted file mode 100644 index 03974f755a..0000000000 --- a/includes/filebackend/FileBackend.php +++ /dev/null @@ -1,1545 +0,0 @@ -//". - * The "backend" portion is unique name for MediaWiki to refer to a backend, while - * the "container" portion is a top-level directory of the backend. The "path" portion - * is a relative path that uses UNIX file system (FS) notation, though any particular - * backend may not actually be using a local filesystem. Therefore, the relative paths - * are only virtual. - * - * Backend contents are stored under wiki-specific container names by default. - * Global (qualified) backends are achieved by configuring the "wiki ID" to a constant. - * For legacy reasons, the FSFileBackend class allows manually setting the paths of - * containers to ones that do not respect the "wiki ID". - * - * In key/value (object) stores, containers are the only hierarchy (the rest is emulated). - * FS-based backends are somewhat more restrictive due to the existence of real - * directory files; a regular file cannot have the same name as a directory. Other - * backends with virtual directories may not have this limitation. Callers should - * store files in such a way that no files and directories are under the same path. - * - * In general, this class allows for callers to access storage through the same - * interface, without regard to the underlying storage system. However, calling code - * must follow certain patterns and be aware of certain things to ensure compatibility: - * - a) Always call prepare() on the parent directory before trying to put a file there; - * key/value stores only need the container to exist first, but filesystems need - * all the parent directories to exist first (prepare() is aware of all this) - * - b) Always call clean() on a directory when it might become empty to avoid empty - * directory buildup on filesystems; key/value stores never have empty directories, - * so doing this helps preserve consistency in both cases - * - c) Likewise, do not rely on the existence of empty directories for anything; - * calling directoryExists() on a path that prepare() was previously called on - * will return false for key/value stores if there are no files under that path - * - d) Never alter the resulting FSFile returned from getLocalReference(), as it could - * either be a copy of the source file in /tmp or the original source file itself - * - e) Use a file layout that results in never attempting to store files over directories - * or directories over files; key/value stores allow this but filesystems do not - * - f) Use ASCII file names (e.g. base32, IDs, hashes) to avoid Unicode issues in Windows - * - g) Do not assume that move operations are atomic (difficult with key/value stores) - * - h) Do not assume that file stat or read operations always have immediate consistency; - * various methods have a "latest" flag that should always be used if up-to-date - * information is required (this trades performance for correctness as needed) - * - i) Do not assume that directory listings have immediate consistency - * - * Methods of subclasses should avoid throwing exceptions at all costs. - * As a corollary, external dependencies should be kept to a minimum. - * - * @ingroup FileBackend - * @since 1.19 - */ -abstract class FileBackend { - /** @var string Unique backend name */ - protected $name; - - /** @var string Unique wiki name */ - protected $wikiId; - - /** @var string Read-only explanation message */ - protected $readOnly; - - /** @var string When to do operations in parallel */ - protected $parallelize; - - /** @var int How many operations can be done in parallel */ - protected $concurrency; - - /** @var LockManager */ - protected $lockManager; - - /** @var FileJournal */ - protected $fileJournal; - - /** Bitfield flags for supported features */ - const ATTR_HEADERS = 1; // files can be tagged with standard HTTP headers - const ATTR_METADATA = 2; // files can be stored with metadata key/values - const ATTR_UNICODE_PATHS = 4; // files can have Unicode paths (not just ASCII) - - /** - * Create a new backend instance from configuration. - * This should only be called from within FileBackendGroup. - * - * @param array $config Parameters include: - * - name : The unique name of this backend. - * This should consist of alphanumberic, '-', and '_' characters. - * This name should not be changed after use (e.g. with journaling). - * Note that the name is *not* used in actual container names. - * - wikiId : Prefix to container names that is unique to this backend. - * It should only consist of alphanumberic, '-', and '_' characters. - * This ID is what avoids collisions if multiple logical backends - * use the same storage system, so this should be set carefully. - * - lockManager : LockManager object to use for any file locking. - * If not provided, then no file locking will be enforced. - * - fileJournal : FileJournal object to use for logging changes to files. - * If not provided, then change journaling will be disabled. - * - readOnly : Write operations are disallowed if this is a non-empty string. - * It should be an explanation for the backend being read-only. - * - parallelize : When to do file operations in parallel (when possible). - * Allowed values are "implicit", "explicit" and "off". - * - concurrency : How many file operations can be done in parallel. - * @throws FileBackendException - */ - public function __construct( array $config ) { - $this->name = $config['name']; - $this->wikiId = $config['wikiId']; // e.g. "my_wiki-en_" - if ( !preg_match( '!^[a-zA-Z0-9-_]{1,255}$!', $this->name ) ) { - throw new FileBackendException( "Backend name '{$this->name}' is invalid." ); - } elseif ( !is_string( $this->wikiId ) ) { - throw new FileBackendException( "Backend wiki ID not provided for '{$this->name}'." ); - } - $this->lockManager = isset( $config['lockManager'] ) - ? $config['lockManager'] - : new NullLockManager( [] ); - $this->fileJournal = isset( $config['fileJournal'] ) - ? $config['fileJournal'] - : FileJournal::factory( [ 'class' => 'NullFileJournal' ], $this->name ); - $this->readOnly = isset( $config['readOnly'] ) - ? (string)$config['readOnly'] - : ''; - $this->parallelize = isset( $config['parallelize'] ) - ? (string)$config['parallelize'] - : 'off'; - $this->concurrency = isset( $config['concurrency'] ) - ? (int)$config['concurrency'] - : 50; - } - - /** - * Get the unique backend name. - * We may have multiple different backends of the same type. - * For example, we can have two Swift backends using different proxies. - * - * @return string - */ - final public function getName() { - return $this->name; - } - - /** - * Get the wiki identifier used for this backend (possibly empty). - * Note that this might *not* be in the same format as wfWikiID(). - * - * @return string - * @since 1.20 - */ - final public function getWikiId() { - return $this->wikiId; - } - - /** - * Check if this backend is read-only - * - * @return bool - */ - final public function isReadOnly() { - return ( $this->readOnly != '' ); - } - - /** - * Get an explanatory message if this backend is read-only - * - * @return string|bool Returns false if the backend is not read-only - */ - final public function getReadOnlyReason() { - return ( $this->readOnly != '' ) ? $this->readOnly : false; - } - - /** - * Get the a bitfield of extra features supported by the backend medium - * - * @return int Bitfield of FileBackend::ATTR_* flags - * @since 1.23 - */ - public function getFeatures() { - return self::ATTR_UNICODE_PATHS; - } - - /** - * Check if the backend medium supports a field of extra features - * - * @param int $bitfield Bitfield of FileBackend::ATTR_* flags - * @return bool - * @since 1.23 - */ - final public function hasFeatures( $bitfield ) { - return ( $this->getFeatures() & $bitfield ) === $bitfield; - } - - /** - * This is the main entry point into the backend for write operations. - * Callers supply an ordered list of operations to perform as a transaction. - * Files will be locked, the stat cache cleared, and then the operations attempted. - * If any serious errors occur, all attempted operations will be rolled back. - * - * $ops is an array of arrays. The outer array holds a list of operations. - * Each inner array is a set of key value pairs that specify an operation. - * - * Supported operations and their parameters. The supported actions are: - * - create - * - store - * - copy - * - move - * - delete - * - describe (since 1.21) - * - null - * - * FSFile/TempFSFile object support was added in 1.27. - * - * a) Create a new file in storage with the contents of a string - * @code - * array( - * 'op' => 'create', - * 'dst' => , - * 'content' => , - * 'overwrite' => , - * 'overwriteSame' => , - * 'headers' => # since 1.21 - * ); - * @endcode - * - * b) Copy a file system file into storage - * @code - * array( - * 'op' => 'store', - * 'src' => , - * 'dst' => , - * 'overwrite' => , - * 'overwriteSame' => , - * 'headers' => # since 1.21 - * ) - * @endcode - * - * c) Copy a file within storage - * @code - * array( - * 'op' => 'copy', - * 'src' => , - * 'dst' => , - * 'overwrite' => , - * 'overwriteSame' => , - * 'ignoreMissingSource' => , # since 1.21 - * 'headers' => # since 1.21 - * ) - * @endcode - * - * d) Move a file within storage - * @code - * array( - * 'op' => 'move', - * 'src' => , - * 'dst' => , - * 'overwrite' => , - * 'overwriteSame' => , - * 'ignoreMissingSource' => , # since 1.21 - * 'headers' => # since 1.21 - * ) - * @endcode - * - * e) Delete a file within storage - * @code - * array( - * 'op' => 'delete', - * 'src' => , - * 'ignoreMissingSource' => - * ) - * @endcode - * - * f) Update metadata for a file within storage - * @code - * array( - * 'op' => 'describe', - * 'src' => , - * 'headers' => - * ) - * @endcode - * - * g) Do nothing (no-op) - * @code - * array( - * 'op' => 'null', - * ) - * @endcode - * - * Boolean flags for operations (operation-specific): - * - ignoreMissingSource : The operation will simply succeed and do - * nothing if the source file does not exist. - * - overwrite : Any destination file will be overwritten. - * - overwriteSame : If a file already exists at the destination with the - * same contents, then do nothing to the destination file - * instead of giving an error. This does not compare headers. - * This option is ignored if 'overwrite' is already provided. - * - headers : If supplied, the result of merging these headers with any - * existing source file headers (replacing conflicting ones) - * will be set as the destination file headers. Headers are - * deleted if their value is set to the empty string. When a - * file has headers they are included in responses to GET and - * HEAD requests to the backing store for that file. - * Header values should be no larger than 255 bytes, except for - * Content-Disposition. The system might ignore or truncate any - * headers that are too long to store (exact limits will vary). - * Backends that don't support metadata ignore this. (since 1.21) - * - * $opts is an associative of boolean flags, including: - * - force : Operation precondition errors no longer trigger an abort. - * Any remaining operations are still attempted. Unexpected - * failures may still cause remaining operations to be aborted. - * - nonLocking : No locks are acquired for the operations. - * This can increase performance for non-critical writes. - * This has no effect unless the 'force' flag is set. - * - nonJournaled : Don't log this operation batch in the file journal. - * This limits the ability of recovery scripts. - * - parallelize : Try to do operations in parallel when possible. - * - bypassReadOnly : Allow writes in read-only mode. (since 1.20) - * - preserveCache : Don't clear the process cache before checking files. - * This should only be used if all entries in the process - * cache were added after the files were already locked. (since 1.20) - * - * @remarks Remarks on locking: - * File system paths given to operations should refer to files that are - * already locked or otherwise safe from modification from other processes. - * Normally these files will be new temp files, which should be adequate. - * - * @par Return value: - * - * This returns a Status, which contains all warnings and fatals that occurred - * during the operation. The 'failCount', 'successCount', and 'success' members - * will reflect each operation attempted. - * - * The status will be "OK" unless: - * - a) unexpected operation errors occurred (network partitions, disk full...) - * - b) significant operation errors occurred and 'force' was not set - * - * @param array $ops List of operations to execute in order - * @param array $opts Batch operation options - * @return Status - */ - final public function doOperations( array $ops, array $opts = [] ) { - if ( empty( $opts['bypassReadOnly'] ) && $this->isReadOnly() ) { - return Status::newFatal( 'backend-fail-readonly', $this->name, $this->readOnly ); - } - if ( !count( $ops ) ) { - return Status::newGood(); // nothing to do - } - - $ops = $this->resolveFSFileObjects( $ops ); - if ( empty( $opts['force'] ) ) { // sanity - unset( $opts['nonLocking'] ); - } - - /** @noinspection PhpUnusedLocalVariableInspection */ - $scope = $this->getScopedPHPBehaviorForOps(); // try to ignore client aborts - - return $this->doOperationsInternal( $ops, $opts ); - } - - /** - * @see FileBackend::doOperations() - * @param array $ops - * @param array $opts - */ - abstract protected function doOperationsInternal( array $ops, array $opts ); - - /** - * Same as doOperations() except it takes a single operation. - * If you are doing a batch of operations that should either - * all succeed or all fail, then use that function instead. - * - * @see FileBackend::doOperations() - * - * @param array $op Operation - * @param array $opts Operation options - * @return Status - */ - final public function doOperation( array $op, array $opts = [] ) { - return $this->doOperations( [ $op ], $opts ); - } - - /** - * Performs a single create operation. - * This sets $params['op'] to 'create' and passes it to doOperation(). - * - * @see FileBackend::doOperation() - * - * @param array $params Operation parameters - * @param array $opts Operation options - * @return Status - */ - final public function create( array $params, array $opts = [] ) { - return $this->doOperation( [ 'op' => 'create' ] + $params, $opts ); - } - - /** - * Performs a single store operation. - * This sets $params['op'] to 'store' and passes it to doOperation(). - * - * @see FileBackend::doOperation() - * - * @param array $params Operation parameters - * @param array $opts Operation options - * @return Status - */ - final public function store( array $params, array $opts = [] ) { - return $this->doOperation( [ 'op' => 'store' ] + $params, $opts ); - } - - /** - * Performs a single copy operation. - * This sets $params['op'] to 'copy' and passes it to doOperation(). - * - * @see FileBackend::doOperation() - * - * @param array $params Operation parameters - * @param array $opts Operation options - * @return Status - */ - final public function copy( array $params, array $opts = [] ) { - return $this->doOperation( [ 'op' => 'copy' ] + $params, $opts ); - } - - /** - * Performs a single move operation. - * This sets $params['op'] to 'move' and passes it to doOperation(). - * - * @see FileBackend::doOperation() - * - * @param array $params Operation parameters - * @param array $opts Operation options - * @return Status - */ - final public function move( array $params, array $opts = [] ) { - return $this->doOperation( [ 'op' => 'move' ] + $params, $opts ); - } - - /** - * Performs a single delete operation. - * This sets $params['op'] to 'delete' and passes it to doOperation(). - * - * @see FileBackend::doOperation() - * - * @param array $params Operation parameters - * @param array $opts Operation options - * @return Status - */ - final public function delete( array $params, array $opts = [] ) { - return $this->doOperation( [ 'op' => 'delete' ] + $params, $opts ); - } - - /** - * Performs a single describe operation. - * This sets $params['op'] to 'describe' and passes it to doOperation(). - * - * @see FileBackend::doOperation() - * - * @param array $params Operation parameters - * @param array $opts Operation options - * @return Status - * @since 1.21 - */ - final public function describe( array $params, array $opts = [] ) { - return $this->doOperation( [ 'op' => 'describe' ] + $params, $opts ); - } - - /** - * Perform a set of independent file operations on some files. - * - * This does no locking, nor journaling, and possibly no stat calls. - * Any destination files that already exist will be overwritten. - * This should *only* be used on non-original files, like cache files. - * - * Supported operations and their parameters: - * - create - * - store - * - copy - * - move - * - delete - * - describe (since 1.21) - * - null - * - * FSFile/TempFSFile object support was added in 1.27. - * - * a) Create a new file in storage with the contents of a string - * @code - * array( - * 'op' => 'create', - * 'dst' => , - * 'content' => , - * 'headers' => # since 1.21 - * ) - * @endcode - * - * b) Copy a file system file into storage - * @code - * array( - * 'op' => 'store', - * 'src' => , - * 'dst' => , - * 'headers' => # since 1.21 - * ) - * @endcode - * - * c) Copy a file within storage - * @code - * array( - * 'op' => 'copy', - * 'src' => , - * 'dst' => , - * 'ignoreMissingSource' => , # since 1.21 - * 'headers' => # since 1.21 - * ) - * @endcode - * - * d) Move a file within storage - * @code - * array( - * 'op' => 'move', - * 'src' => , - * 'dst' => , - * 'ignoreMissingSource' => , # since 1.21 - * 'headers' => # since 1.21 - * ) - * @endcode - * - * e) Delete a file within storage - * @code - * array( - * 'op' => 'delete', - * 'src' => , - * 'ignoreMissingSource' => - * ) - * @endcode - * - * f) Update metadata for a file within storage - * @code - * array( - * 'op' => 'describe', - * 'src' => , - * 'headers' => - * ) - * @endcode - * - * g) Do nothing (no-op) - * @code - * array( - * 'op' => 'null', - * ) - * @endcode - * - * @par Boolean flags for operations (operation-specific): - * - ignoreMissingSource : The operation will simply succeed and do - * nothing if the source file does not exist. - * - headers : If supplied with a header name/value map, the backend will - * reply with these headers when GETs/HEADs of the destination - * file are made. Header values should be smaller than 256 bytes. - * Content-Disposition headers can be longer, though the system - * might ignore or truncate ones that are too long to store. - * Existing headers will remain, but these will replace any - * conflicting previous headers, and headers will be removed - * if they are set to an empty string. - * Backends that don't support metadata ignore this. (since 1.21) - * - * $opts is an associative of boolean flags, including: - * - bypassReadOnly : Allow writes in read-only mode (since 1.20) - * - * @par Return value: - * This returns a Status, which contains all warnings and fatals that occurred - * during the operation. The 'failCount', 'successCount', and 'success' members - * will reflect each operation attempted for the given files. The status will be - * considered "OK" as long as no fatal errors occurred. - * - * @param array $ops Set of operations to execute - * @param array $opts Batch operation options - * @return Status - * @since 1.20 - */ - final public function doQuickOperations( array $ops, array $opts = [] ) { - if ( empty( $opts['bypassReadOnly'] ) && $this->isReadOnly() ) { - return Status::newFatal( 'backend-fail-readonly', $this->name, $this->readOnly ); - } - if ( !count( $ops ) ) { - return Status::newGood(); // nothing to do - } - - $ops = $this->resolveFSFileObjects( $ops ); - foreach ( $ops as &$op ) { - $op['overwrite'] = true; // avoids RTTs in key/value stores - } - - /** @noinspection PhpUnusedLocalVariableInspection */ - $scope = $this->getScopedPHPBehaviorForOps(); // try to ignore client aborts - - return $this->doQuickOperationsInternal( $ops ); - } - - /** - * @see FileBackend::doQuickOperations() - * @param array $ops - * @since 1.20 - */ - abstract protected function doQuickOperationsInternal( array $ops ); - - /** - * Same as doQuickOperations() except it takes a single operation. - * If you are doing a batch of operations, then use that function instead. - * - * @see FileBackend::doQuickOperations() - * - * @param array $op Operation - * @return Status - * @since 1.20 - */ - final public function doQuickOperation( array $op ) { - return $this->doQuickOperations( [ $op ] ); - } - - /** - * Performs a single quick create operation. - * This sets $params['op'] to 'create' and passes it to doQuickOperation(). - * - * @see FileBackend::doQuickOperation() - * - * @param array $params Operation parameters - * @return Status - * @since 1.20 - */ - final public function quickCreate( array $params ) { - return $this->doQuickOperation( [ 'op' => 'create' ] + $params ); - } - - /** - * Performs a single quick store operation. - * This sets $params['op'] to 'store' and passes it to doQuickOperation(). - * - * @see FileBackend::doQuickOperation() - * - * @param array $params Operation parameters - * @return Status - * @since 1.20 - */ - final public function quickStore( array $params ) { - return $this->doQuickOperation( [ 'op' => 'store' ] + $params ); - } - - /** - * Performs a single quick copy operation. - * This sets $params['op'] to 'copy' and passes it to doQuickOperation(). - * - * @see FileBackend::doQuickOperation() - * - * @param array $params Operation parameters - * @return Status - * @since 1.20 - */ - final public function quickCopy( array $params ) { - return $this->doQuickOperation( [ 'op' => 'copy' ] + $params ); - } - - /** - * Performs a single quick move operation. - * This sets $params['op'] to 'move' and passes it to doQuickOperation(). - * - * @see FileBackend::doQuickOperation() - * - * @param array $params Operation parameters - * @return Status - * @since 1.20 - */ - final public function quickMove( array $params ) { - return $this->doQuickOperation( [ 'op' => 'move' ] + $params ); - } - - /** - * Performs a single quick delete operation. - * This sets $params['op'] to 'delete' and passes it to doQuickOperation(). - * - * @see FileBackend::doQuickOperation() - * - * @param array $params Operation parameters - * @return Status - * @since 1.20 - */ - final public function quickDelete( array $params ) { - return $this->doQuickOperation( [ 'op' => 'delete' ] + $params ); - } - - /** - * Performs a single quick describe operation. - * This sets $params['op'] to 'describe' and passes it to doQuickOperation(). - * - * @see FileBackend::doQuickOperation() - * - * @param array $params Operation parameters - * @return Status - * @since 1.21 - */ - final public function quickDescribe( array $params ) { - return $this->doQuickOperation( [ 'op' => 'describe' ] + $params ); - } - - /** - * Concatenate a list of storage files into a single file system file. - * The target path should refer to a file that is already locked or - * otherwise safe from modification from other processes. Normally, - * the file will be a new temp file, which should be adequate. - * - * @param array $params Operation parameters, include: - * - srcs : ordered source storage paths (e.g. chunk1, chunk2, ...) - * - dst : file system path to 0-byte temp file - * - parallelize : try to do operations in parallel when possible - * @return Status - */ - abstract public function concatenate( array $params ); - - /** - * Prepare a storage directory for usage. - * This will create any required containers and parent directories. - * Backends using key/value stores only need to create the container. - * - * The 'noAccess' and 'noListing' parameters works the same as in secure(), - * except they are only applied *if* the directory/container had to be created. - * These flags should always be set for directories that have private files. - * However, setting them is not guaranteed to actually do anything. - * Additional server configuration may be needed to achieve the desired effect. - * - * @param array $params Parameters include: - * - dir : storage directory - * - noAccess : try to deny file access (since 1.20) - * - noListing : try to deny file listing (since 1.20) - * - bypassReadOnly : allow writes in read-only mode (since 1.20) - * @return Status - */ - final public function prepare( array $params ) { - if ( empty( $params['bypassReadOnly'] ) && $this->isReadOnly() ) { - return Status::newFatal( 'backend-fail-readonly', $this->name, $this->readOnly ); - } - /** @noinspection PhpUnusedLocalVariableInspection */ - $scope = $this->getScopedPHPBehaviorForOps(); // try to ignore client aborts - return $this->doPrepare( $params ); - } - - /** - * @see FileBackend::prepare() - * @param array $params - */ - abstract protected function doPrepare( array $params ); - - /** - * Take measures to block web access to a storage directory and - * the container it belongs to. FS backends might add .htaccess - * files whereas key/value store backends might revoke container - * access to the storage user representing end-users in web requests. - * - * This is not guaranteed to actually make files or listings publically hidden. - * Additional server configuration may be needed to achieve the desired effect. - * - * @param array $params Parameters include: - * - dir : storage directory - * - noAccess : try to deny file access - * - noListing : try to deny file listing - * - bypassReadOnly : allow writes in read-only mode (since 1.20) - * @return Status - */ - final public function secure( array $params ) { - if ( empty( $params['bypassReadOnly'] ) && $this->isReadOnly() ) { - return Status::newFatal( 'backend-fail-readonly', $this->name, $this->readOnly ); - } - /** @noinspection PhpUnusedLocalVariableInspection */ - $scope = $this->getScopedPHPBehaviorForOps(); // try to ignore client aborts - return $this->doSecure( $params ); - } - - /** - * @see FileBackend::secure() - * @param array $params - */ - abstract protected function doSecure( array $params ); - - /** - * Remove measures to block web access to a storage directory and - * the container it belongs to. FS backends might remove .htaccess - * files whereas key/value store backends might grant container - * access to the storage user representing end-users in web requests. - * This essentially can undo the result of secure() calls. - * - * This is not guaranteed to actually make files or listings publically viewable. - * Additional server configuration may be needed to achieve the desired effect. - * - * @param array $params Parameters include: - * - dir : storage directory - * - access : try to allow file access - * - listing : try to allow file listing - * - bypassReadOnly : allow writes in read-only mode (since 1.20) - * @return Status - * @since 1.20 - */ - final public function publish( array $params ) { - if ( empty( $params['bypassReadOnly'] ) && $this->isReadOnly() ) { - return Status::newFatal( 'backend-fail-readonly', $this->name, $this->readOnly ); - } - /** @noinspection PhpUnusedLocalVariableInspection */ - $scope = $this->getScopedPHPBehaviorForOps(); // try to ignore client aborts - return $this->doPublish( $params ); - } - - /** - * @see FileBackend::publish() - * @param array $params - */ - abstract protected function doPublish( array $params ); - - /** - * Delete a storage directory if it is empty. - * Backends using key/value stores may do nothing unless the directory - * is that of an empty container, in which case it will be deleted. - * - * @param array $params Parameters include: - * - dir : storage directory - * - recursive : recursively delete empty subdirectories first (since 1.20) - * - bypassReadOnly : allow writes in read-only mode (since 1.20) - * @return Status - */ - final public function clean( array $params ) { - if ( empty( $params['bypassReadOnly'] ) && $this->isReadOnly() ) { - return Status::newFatal( 'backend-fail-readonly', $this->name, $this->readOnly ); - } - /** @noinspection PhpUnusedLocalVariableInspection */ - $scope = $this->getScopedPHPBehaviorForOps(); // try to ignore client aborts - return $this->doClean( $params ); - } - - /** - * @see FileBackend::clean() - * @param array $params - */ - abstract protected function doClean( array $params ); - - /** - * Enter file operation scope. - * This just makes PHP ignore user aborts/disconnects until the return - * value leaves scope. This returns null and does nothing in CLI mode. - * - * @return ScopedCallback|null - */ - final protected function getScopedPHPBehaviorForOps() { - if ( PHP_SAPI != 'cli' ) { // http://bugs.php.net/bug.php?id=47540 - $old = ignore_user_abort( true ); // avoid half-finished operations - return new ScopedCallback( function () use ( $old ) { - ignore_user_abort( $old ); - } ); - } - - return null; - } - - /** - * Check if a file exists at a storage path in the backend. - * This returns false if only a directory exists at the path. - * - * @param array $params Parameters include: - * - src : source storage path - * - latest : use the latest available data - * @return bool|null Returns null on failure - */ - abstract public function fileExists( array $params ); - - /** - * Get the last-modified timestamp of the file at a storage path. - * - * @param array $params Parameters include: - * - src : source storage path - * - latest : use the latest available data - * @return string|bool TS_MW timestamp or false on failure - */ - abstract public function getFileTimestamp( array $params ); - - /** - * Get the contents of a file at a storage path in the backend. - * This should be avoided for potentially large files. - * - * @param array $params Parameters include: - * - src : source storage path - * - latest : use the latest available data - * @return string|bool Returns false on failure - */ - final public function getFileContents( array $params ) { - $contents = $this->getFileContentsMulti( - [ 'srcs' => [ $params['src'] ] ] + $params ); - - return $contents[$params['src']]; - } - - /** - * Like getFileContents() except it takes an array of storage paths - * and returns a map of storage paths to strings (or null on failure). - * The map keys (paths) are in the same order as the provided list of paths. - * - * @see FileBackend::getFileContents() - * - * @param array $params Parameters include: - * - srcs : list of source storage paths - * - latest : use the latest available data - * - parallelize : try to do operations in parallel when possible - * @return array Map of (path name => string or false on failure) - * @since 1.20 - */ - abstract public function getFileContentsMulti( array $params ); - - /** - * Get metadata about a file at a storage path in the backend. - * If the file does not exist, then this returns false. - * Otherwise, the result is an associative array that includes: - * - headers : map of HTTP headers used for GET/HEAD requests (name => value) - * - metadata : map of file metadata (name => value) - * Metadata keys and headers names will be returned in all lower-case. - * Additional values may be included for internal use only. - * - * Use FileBackend::hasFeatures() to check how well this is supported. - * - * @param array $params - * $params include: - * - src : source storage path - * - latest : use the latest available data - * @return array|bool Returns false on failure - * @since 1.23 - */ - abstract public function getFileXAttributes( array $params ); - - /** - * Get the size (bytes) of a file at a storage path in the backend. - * - * @param array $params Parameters include: - * - src : source storage path - * - latest : use the latest available data - * @return int|bool Returns false on failure - */ - abstract public function getFileSize( array $params ); - - /** - * Get quick information about a file at a storage path in the backend. - * If the file does not exist, then this returns false. - * Otherwise, the result is an associative array that includes: - * - mtime : the last-modified timestamp (TS_MW) - * - size : the file size (bytes) - * Additional values may be included for internal use only. - * - * @param array $params Parameters include: - * - src : source storage path - * - latest : use the latest available data - * @return array|bool|null Returns null on failure - */ - abstract public function getFileStat( array $params ); - - /** - * Get a SHA-1 hash of the file at a storage path in the backend. - * - * @param array $params Parameters include: - * - src : source storage path - * - latest : use the latest available data - * @return string|bool Hash string or false on failure - */ - abstract public function getFileSha1Base36( array $params ); - - /** - * Get the properties of the file at a storage path in the backend. - * This gives the result of FSFile::getProps() on a local copy of the file. - * - * @param array $params Parameters include: - * - src : source storage path - * - latest : use the latest available data - * @return array Returns FSFile::placeholderProps() on failure - */ - abstract public function getFileProps( array $params ); - - /** - * Stream the file at a storage path in the backend. - * If the file does not exists, an HTTP 404 error will be given. - * Appropriate HTTP headers (Status, Content-Type, Content-Length) - * will be sent if streaming began, while none will be sent otherwise. - * Implementations should flush the output buffer before sending data. - * - * @param array $params Parameters include: - * - src : source storage path - * - headers : list of additional HTTP headers to send on success - * - latest : use the latest available data - * @return Status - */ - abstract public function streamFile( array $params ); - - /** - * Returns a file system file, identical to the file at a storage path. - * The file returned is either: - * - a) A local copy of the file at a storage path in the backend. - * The temporary copy will have the same extension as the source. - * - b) An original of the file at a storage path in the backend. - * Temporary files may be purged when the file object falls out of scope. - * - * Write operations should *never* be done on this file as some backends - * may do internal tracking or may be instances of FileBackendMultiWrite. - * In that later case, there are copies of the file that must stay in sync. - * Additionally, further calls to this function may return the same file. - * - * @param array $params Parameters include: - * - src : source storage path - * - latest : use the latest available data - * @return FSFile|null Returns null on failure - */ - final public function getLocalReference( array $params ) { - $fsFiles = $this->getLocalReferenceMulti( - [ 'srcs' => [ $params['src'] ] ] + $params ); - - return $fsFiles[$params['src']]; - } - - /** - * Like getLocalReference() except it takes an array of storage paths - * and returns a map of storage paths to FSFile objects (or null on failure). - * The map keys (paths) are in the same order as the provided list of paths. - * - * @see FileBackend::getLocalReference() - * - * @param array $params Parameters include: - * - srcs : list of source storage paths - * - latest : use the latest available data - * - parallelize : try to do operations in parallel when possible - * @return array Map of (path name => FSFile or null on failure) - * @since 1.20 - */ - abstract public function getLocalReferenceMulti( array $params ); - - /** - * Get a local copy on disk of the file at a storage path in the backend. - * The temporary copy will have the same file extension as the source. - * Temporary files may be purged when the file object falls out of scope. - * - * @param array $params Parameters include: - * - src : source storage path - * - latest : use the latest available data - * @return TempFSFile|null Returns null on failure - */ - final public function getLocalCopy( array $params ) { - $tmpFiles = $this->getLocalCopyMulti( - [ 'srcs' => [ $params['src'] ] ] + $params ); - - return $tmpFiles[$params['src']]; - } - - /** - * Like getLocalCopy() except it takes an array of storage paths and - * returns a map of storage paths to TempFSFile objects (or null on failure). - * The map keys (paths) are in the same order as the provided list of paths. - * - * @see FileBackend::getLocalCopy() - * - * @param array $params Parameters include: - * - srcs : list of source storage paths - * - latest : use the latest available data - * - parallelize : try to do operations in parallel when possible - * @return array Map of (path name => TempFSFile or null on failure) - * @since 1.20 - */ - abstract public function getLocalCopyMulti( array $params ); - - /** - * Return an HTTP URL to a given file that requires no authentication to use. - * The URL may be pre-authenticated (via some token in the URL) and temporary. - * This will return null if the backend cannot make an HTTP URL for the file. - * - * This is useful for key/value stores when using scripts that seek around - * large files and those scripts (and the backend) support HTTP Range headers. - * Otherwise, one would need to use getLocalReference(), which involves loading - * the entire file on to local disk. - * - * @param array $params Parameters include: - * - src : source storage path - * - ttl : lifetime (seconds) if pre-authenticated; default is 1 day - * @return string|null - * @since 1.21 - */ - abstract public function getFileHttpUrl( array $params ); - - /** - * Check if a directory exists at a given storage path. - * Backends using key/value stores will check if the path is a - * virtual directory, meaning there are files under the given directory. - * - * Storage backends with eventual consistency might return stale data. - * - * @param array $params Parameters include: - * - dir : storage directory - * @return bool|null Returns null on failure - * @since 1.20 - */ - abstract public function directoryExists( array $params ); - - /** - * Get an iterator to list *all* directories under a storage directory. - * If the directory is of the form "mwstore://backend/container", - * then all directories in the container will be listed. - * If the directory is of form "mwstore://backend/container/dir", - * then all directories directly under that directory will be listed. - * Results will be storage directories relative to the given directory. - * - * Storage backends with eventual consistency might return stale data. - * - * Failures during iteration can result in FileBackendError exceptions (since 1.22). - * - * @param array $params Parameters include: - * - dir : storage directory - * - topOnly : only return direct child dirs of the directory - * @return Traversable|array|null Returns null on failure - * @since 1.20 - */ - abstract public function getDirectoryList( array $params ); - - /** - * Same as FileBackend::getDirectoryList() except only lists - * directories that are immediately under the given directory. - * - * Storage backends with eventual consistency might return stale data. - * - * Failures during iteration can result in FileBackendError exceptions (since 1.22). - * - * @param array $params Parameters include: - * - dir : storage directory - * @return Traversable|array|null Returns null on failure - * @since 1.20 - */ - final public function getTopDirectoryList( array $params ) { - return $this->getDirectoryList( [ 'topOnly' => true ] + $params ); - } - - /** - * Get an iterator to list *all* stored files under a storage directory. - * If the directory is of the form "mwstore://backend/container", - * then all files in the container will be listed. - * If the directory is of form "mwstore://backend/container/dir", - * then all files under that directory will be listed. - * Results will be storage paths relative to the given directory. - * - * Storage backends with eventual consistency might return stale data. - * - * Failures during iteration can result in FileBackendError exceptions (since 1.22). - * - * @param array $params Parameters include: - * - dir : storage directory - * - topOnly : only return direct child files of the directory (since 1.20) - * - adviseStat : set to true if stat requests will be made on the files (since 1.22) - * @return Traversable|array|null Returns null on failure - */ - abstract public function getFileList( array $params ); - - /** - * Same as FileBackend::getFileList() except only lists - * files that are immediately under the given directory. - * - * Storage backends with eventual consistency might return stale data. - * - * Failures during iteration can result in FileBackendError exceptions (since 1.22). - * - * @param array $params Parameters include: - * - dir : storage directory - * - adviseStat : set to true if stat requests will be made on the files (since 1.22) - * @return Traversable|array|null Returns null on failure - * @since 1.20 - */ - final public function getTopFileList( array $params ) { - return $this->getFileList( [ 'topOnly' => true ] + $params ); - } - - /** - * Preload persistent file stat cache and property cache into in-process cache. - * This should be used when stat calls will be made on a known list of a many files. - * - * @see FileBackend::getFileStat() - * - * @param array $paths Storage paths - */ - abstract public function preloadCache( array $paths ); - - /** - * Invalidate any in-process file stat and property cache. - * If $paths is given, then only the cache for those files will be cleared. - * - * @see FileBackend::getFileStat() - * - * @param array $paths Storage paths (optional) - */ - abstract public function clearCache( array $paths = null ); - - /** - * Preload file stat information (concurrently if possible) into in-process cache. - * - * This should be used when stat calls will be made on a known list of a many files. - * This does not make use of the persistent file stat cache. - * - * @see FileBackend::getFileStat() - * - * @param array $params Parameters include: - * - srcs : list of source storage paths - * - latest : use the latest available data - * @return bool All requests proceeded without I/O errors (since 1.24) - * @since 1.23 - */ - abstract public function preloadFileStat( array $params ); - - /** - * Lock the files at the given storage paths in the backend. - * This will either lock all the files or none (on failure). - * - * Callers should consider using getScopedFileLocks() instead. - * - * @param array $paths Storage paths - * @param int $type LockManager::LOCK_* constant - * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.24) - * @return Status - */ - final public function lockFiles( array $paths, $type, $timeout = 0 ) { - $paths = array_map( 'FileBackend::normalizeStoragePath', $paths ); - - return $this->lockManager->lock( $paths, $type, $timeout ); - } - - /** - * Unlock the files at the given storage paths in the backend. - * - * @param array $paths Storage paths - * @param int $type LockManager::LOCK_* constant - * @return Status - */ - final public function unlockFiles( array $paths, $type ) { - $paths = array_map( 'FileBackend::normalizeStoragePath', $paths ); - - return $this->lockManager->unlock( $paths, $type ); - } - - /** - * Lock the files at the given storage paths in the backend. - * This will either lock all the files or none (on failure). - * On failure, the status object will be updated with errors. - * - * Once the return value goes out scope, the locks will be released and - * the status updated. Unlock fatals will not change the status "OK" value. - * - * @see ScopedLock::factory() - * - * @param array $paths List of storage paths or map of lock types to path lists - * @param int|string $type LockManager::LOCK_* constant or "mixed" - * @param Status $status Status to update on lock/unlock - * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.24) - * @return ScopedLock|null Returns null on failure - */ - final public function getScopedFileLocks( array $paths, $type, Status $status, $timeout = 0 ) { - if ( $type === 'mixed' ) { - foreach ( $paths as &$typePaths ) { - $typePaths = array_map( 'FileBackend::normalizeStoragePath', $typePaths ); - } - } else { - $paths = array_map( 'FileBackend::normalizeStoragePath', $paths ); - } - - return ScopedLock::factory( $this->lockManager, $paths, $type, $status, $timeout ); - } - - /** - * Get an array of scoped locks needed for a batch of file operations. - * - * Normally, FileBackend::doOperations() handles locking, unless - * the 'nonLocking' param is passed in. This function is useful if you - * want the files to be locked for a broader scope than just when the - * files are changing. For example, if you need to update DB metadata, - * you may want to keep the files locked until finished. - * - * @see FileBackend::doOperations() - * - * @param array $ops List of file operations to FileBackend::doOperations() - * @param Status $status Status to update on lock/unlock - * @return ScopedLock|null - * @since 1.20 - */ - abstract public function getScopedLocksForOps( array $ops, Status $status ); - - /** - * Get the root storage path of this backend. - * All container paths are "subdirectories" of this path. - * - * @return string Storage path - * @since 1.20 - */ - final public function getRootStoragePath() { - return "mwstore://{$this->name}"; - } - - /** - * Get the storage path for the given container for this backend - * - * @param string $container Container name - * @return string Storage path - * @since 1.21 - */ - final public function getContainerStoragePath( $container ) { - return $this->getRootStoragePath() . "/{$container}"; - } - - /** - * Get the file journal object for this backend - * - * @return FileJournal - */ - final public function getJournal() { - return $this->fileJournal; - } - - /** - * Convert FSFile 'src' paths to string paths (with an 'srcRef' field set to the FSFile) - * - * The 'srcRef' field keeps any TempFSFile objects in scope for the backend to have it - * around as long it needs (which may vary greatly depending on configuration) - * - * @param array $ops File operation batch for FileBaclend::doOperations() - * @return array File operation batch - */ - protected function resolveFSFileObjects( array $ops ) { - foreach ( $ops as &$op ) { - $src = isset( $op['src'] ) ? $op['src'] : null; - if ( $src instanceof FSFile ) { - $op['srcRef'] = $src; - $op['src'] = $src->getPath(); - } - } - unset( $op ); - - return $ops; - } - - /** - * Check if a given path is a "mwstore://" path. - * This does not do any further validation or any existence checks. - * - * @param string $path - * @return bool - */ - final public static function isStoragePath( $path ) { - return ( strpos( $path, 'mwstore://' ) === 0 ); - } - - /** - * Split a storage path into a backend name, a container name, - * and a relative file path. The relative path may be the empty string. - * This does not do any path normalization or traversal checks. - * - * @param string $storagePath - * @return array (backend, container, rel object) or (null, null, null) - */ - final public static function splitStoragePath( $storagePath ) { - if ( self::isStoragePath( $storagePath ) ) { - // Remove the "mwstore://" prefix and split the path - $parts = explode( '/', substr( $storagePath, 10 ), 3 ); - if ( count( $parts ) >= 2 && $parts[0] != '' && $parts[1] != '' ) { - if ( count( $parts ) == 3 ) { - return $parts; // e.g. "backend/container/path" - } else { - return [ $parts[0], $parts[1], '' ]; // e.g. "backend/container" - } - } - } - - return [ null, null, null ]; - } - - /** - * Normalize a storage path by cleaning up directory separators. - * Returns null if the path is not of the format of a valid storage path. - * - * @param string $storagePath - * @return string|null - */ - final public static function normalizeStoragePath( $storagePath ) { - list( $backend, $container, $relPath ) = self::splitStoragePath( $storagePath ); - if ( $relPath !== null ) { // must be for this backend - $relPath = self::normalizeContainerPath( $relPath ); - if ( $relPath !== null ) { - return ( $relPath != '' ) - ? "mwstore://{$backend}/{$container}/{$relPath}" - : "mwstore://{$backend}/{$container}"; - } - } - - return null; - } - - /** - * Get the parent storage directory of a storage path. - * This returns a path like "mwstore://backend/container", - * "mwstore://backend/container/...", or null if there is no parent. - * - * @param string $storagePath - * @return string|null - */ - final public static function parentStoragePath( $storagePath ) { - $storagePath = dirname( $storagePath ); - list( , , $rel ) = self::splitStoragePath( $storagePath ); - - return ( $rel === null ) ? null : $storagePath; - } - - /** - * Get the final extension from a storage or FS path - * - * @param string $path - * @param string $case One of (rawcase, uppercase, lowercase) (since 1.24) - * @return string - */ - final public static function extensionFromPath( $path, $case = 'lowercase' ) { - $i = strrpos( $path, '.' ); - $ext = $i ? substr( $path, $i + 1 ) : ''; - - if ( $case === 'lowercase' ) { - $ext = strtolower( $ext ); - } elseif ( $case === 'uppercase' ) { - $ext = strtoupper( $ext ); - } - - return $ext; - } - - /** - * Check if a relative path has no directory traversals - * - * @param string $path - * @return bool - * @since 1.20 - */ - final public static function isPathTraversalFree( $path ) { - return ( self::normalizeContainerPath( $path ) !== null ); - } - - /** - * Build a Content-Disposition header value per RFC 6266. - * - * @param string $type One of (attachment, inline) - * @param string $filename Suggested file name (should not contain slashes) - * @throws FileBackendError - * @return string - * @since 1.20 - */ - final public static function makeContentDisposition( $type, $filename = '' ) { - $parts = []; - - $type = strtolower( $type ); - if ( !in_array( $type, [ 'inline', 'attachment' ] ) ) { - throw new FileBackendError( "Invalid Content-Disposition type '$type'." ); - } - $parts[] = $type; - - if ( strlen( $filename ) ) { - $parts[] = "filename*=UTF-8''" . rawurlencode( basename( $filename ) ); - } - - return implode( ';', $parts ); - } - - /** - * Validate and normalize a relative storage path. - * Null is returned if the path involves directory traversal. - * Traversal is insecure for FS backends and broken for others. - * - * This uses the same traversal protection as Title::secureAndSplit(). - * - * @param string $path Storage path relative to a container - * @return string|null - */ - final protected static function normalizeContainerPath( $path ) { - // Normalize directory separators - $path = strtr( $path, '\\', '/' ); - // Collapse any consecutive directory separators - $path = preg_replace( '![/]{2,}!', '/', $path ); - // Remove any leading directory separator - $path = ltrim( $path, '/' ); - // Use the same traversal protection as Title::secureAndSplit() - if ( strpos( $path, '.' ) !== false ) { - if ( - $path === '.' || - $path === '..' || - strpos( $path, './' ) === 0 || - strpos( $path, '../' ) === 0 || - strpos( $path, '/./' ) !== false || - strpos( $path, '/../' ) !== false - ) { - return null; - } - } - - return $path; - } -} - -/** - * Generic file backend exception for checked and unexpected (e.g. config) exceptions - * - * @ingroup FileBackend - * @since 1.23 - */ -class FileBackendException extends Exception { -} - -/** - * File backend exception for checked exceptions (e.g. I/O errors) - * - * @ingroup FileBackend - * @since 1.22 - */ -class FileBackendError extends FileBackendException { -} diff --git a/includes/filebackend/FileBackendGroup.php b/includes/filebackend/FileBackendGroup.php index 57461a48ea..e65a5945ff 100644 --- a/includes/filebackend/FileBackendGroup.php +++ b/includes/filebackend/FileBackendGroup.php @@ -21,6 +21,8 @@ * @ingroup FileBackend * @author Aaron Schulz */ +use \MediaWiki\Logger\LoggerFactory; +use MediaWiki\MediaWikiServices; /** * Class to handle file backend registration @@ -61,7 +63,7 @@ class FileBackendGroup { * Register file backends from the global variables */ protected function initFromGlobals() { - global $wgLocalFileRepo, $wgForeignFileRepos, $wgFileBackends; + global $wgLocalFileRepo, $wgForeignFileRepos, $wgFileBackends, $wgDirectoryMode; // Register explicitly defined backends $this->register( $wgFileBackends, wfConfiguredReadOnlyReason() ); @@ -86,9 +88,6 @@ class FileBackendGroup { $transcodedDir = isset( $info['transcodedDir'] ) ? $info['transcodedDir'] : "{$directory}/transcoded"; - $fileMode = isset( $info['fileMode'] ) - ? $info['fileMode'] - : 0644; // Get the FS backend configuration $autoBackends[] = [ 'name' => $backendName, @@ -101,7 +100,8 @@ class FileBackendGroup { "{$repoName}-deleted" => $deletedDir, "{$repoName}-temp" => "{$directory}/temp" ], - 'fileMode' => $fileMode, + 'fileMode' => isset( $info['fileMode'] ) ? $info['fileMode'] : 0644, + 'directoryMode' => $wgDirectoryMode, ]; } @@ -114,18 +114,18 @@ class FileBackendGroup { * * @param array $configs * @param string|null $readOnlyReason - * @throws FileBackendException + * @throws InvalidArgumentException */ protected function register( array $configs, $readOnlyReason = null ) { foreach ( $configs as $config ) { if ( !isset( $config['name'] ) ) { - throw new FileBackendException( "Cannot register a backend with no name." ); + throw new InvalidArgumentException( "Cannot register a backend with no name." ); } $name = $config['name']; if ( isset( $this->backends[$name] ) ) { - throw new FileBackendException( "Backend with name `{$name}` already registered." ); + throw new LogicException( "Backend with name `{$name}` already registered." ); } elseif ( !isset( $config['class'] ) ) { - throw new FileBackendException( "Backend with name `{$name}` has no class." ); + throw new InvalidArgumentException( "Backend with name `{$name}` has no class." ); } $class = $config['class']; @@ -147,26 +147,23 @@ class FileBackendGroup { * * @param string $name * @return FileBackend - * @throws FileBackendException + * @throws InvalidArgumentException */ public function get( $name ) { - if ( !isset( $this->backends[$name] ) ) { - throw new FileBackendException( "No backend defined with the name `$name`." ); - } // Lazy-load the actual backend instance if ( !isset( $this->backends[$name]['instance'] ) ) { - $class = $this->backends[$name]['class']; - $config = $this->backends[$name]['config']; - $config['wikiId'] = isset( $config['wikiId'] ) - ? $config['wikiId'] - : wfWikiID(); // e.g. "my_wiki-en_" - $config['lockManager'] = - LockManagerGroup::singleton( $config['wikiId'] )->get( $config['lockManager'] ); - $config['fileJournal'] = isset( $config['fileJournal'] ) - ? FileJournal::factory( $config['fileJournal'], $name ) - : FileJournal::factory( [ 'class' => 'NullFileJournal' ], $name ); - $config['wanCache'] = ObjectCache::getMainWANInstance(); - $config['mimeCallback'] = [ $this, 'guessMimeInternal' ]; + $config = $this->config( $name ); + + $class = $config['class']; + if ( $class === 'FileBackendMultiWrite' ) { + foreach ( $config['backends'] as $index => $beConfig ) { + if ( isset( $beConfig['template'] ) ) { + // Config is just a modified version of a registered backend's. + // This should only be used when that config is used only by this backend. + $config['backends'][$index] += $this->config( $beConfig['template'] ); + } + } + } $this->backends[$name]['instance'] = new $class( $config ); } @@ -178,16 +175,36 @@ class FileBackendGroup { * Get the config array for a backend object with a given name * * @param string $name - * @return array - * @throws FileBackendException + * @return array Parameters to FileBackend::__construct() + * @throws InvalidArgumentException */ public function config( $name ) { if ( !isset( $this->backends[$name] ) ) { - throw new FileBackendException( "No backend defined with the name `$name`." ); + throw new InvalidArgumentException( "No backend defined with the name `$name`." ); } $class = $this->backends[$name]['class']; - return [ 'class' => $class ] + $this->backends[$name]['config']; + $config = $this->backends[$name]['config']; + $config['class'] = $class; + $config += [ // set defaults + 'wikiId' => wfWikiID(), // e.g. "my_wiki-en_" + 'mimeCallback' => [ $this, 'guessMimeInternal' ], + 'obResetFunc' => 'wfResetOutputBuffers', + 'streamMimeFunc' => [ 'StreamFile', 'contentTypeFromPath' ], + 'tmpDirectory' => wfTempDir(), + 'statusWrapper' => [ 'Status', 'wrap' ], + 'wanCache' => MediaWikiServices::getInstance()->getMainWANObjectCache(), + 'srvCache' => ObjectCache::getLocalServerInstance( 'hash' ), + 'logger' => LoggerFactory::getInstance( 'FileOperation' ), + 'profiler' => Profiler::instance() + ]; + $config['lockManager'] = + LockManagerGroup::singleton( $config['wikiId'] )->get( $config['lockManager'] ); + $config['fileJournal'] = isset( $config['fileJournal'] ) + ? FileJournal::factory( $config['fileJournal'], $name ) + : FileJournal::factory( [ 'class' => 'NullFileJournal' ], $name ); + + return $config; } /** @@ -221,7 +238,7 @@ class FileBackendGroup { if ( !$type && $fsPath ) { $type = $magic->guessMimeType( $fsPath, false ); } elseif ( !$type && strlen( $content ) ) { - $tmpFile = TempFSFile::factory( 'mime_' ); + $tmpFile = TempFSFile::factory( 'mime_', '', wfTempDir() ); file_put_contents( $tmpFile->getPath(), $content ); $type = $magic->guessMimeType( $tmpFile->getPath(), false ); } diff --git a/includes/filebackend/FileBackendMultiWrite.php b/includes/filebackend/FileBackendMultiWrite.php deleted file mode 100644 index 3b2004827d..0000000000 --- a/includes/filebackend/FileBackendMultiWrite.php +++ /dev/null @@ -1,761 +0,0 @@ -syncChecks = isset( $config['syncChecks'] ) - ? $config['syncChecks'] - : self::CHECK_SIZE; - $this->autoResync = isset( $config['autoResync'] ) - ? $config['autoResync'] - : false; - $this->asyncWrites = isset( $config['replication'] ) && $config['replication'] === 'async'; - // Construct backends here rather than via registration - // to keep these backends hidden from outside the proxy. - $namesUsed = []; - foreach ( $config['backends'] as $index => $config ) { - if ( isset( $config['template'] ) ) { - // Config is just a modified version of a registered backend's. - // This should only be used when that config is used only by this backend. - $config = $config + FileBackendGroup::singleton()->config( $config['template'] ); - } - $name = $config['name']; - if ( isset( $namesUsed[$name] ) ) { // don't break FileOp predicates - throw new FileBackendError( "Two or more backends defined with the name $name." ); - } - $namesUsed[$name] = 1; - // Alter certain sub-backend settings for sanity - unset( $config['readOnly'] ); // use proxy backend setting - unset( $config['fileJournal'] ); // use proxy backend journal - unset( $config['lockManager'] ); // lock under proxy backend - $config['wikiId'] = $this->wikiId; // use the proxy backend wiki ID - if ( !empty( $config['isMultiMaster'] ) ) { - if ( $this->masterIndex >= 0 ) { - throw new FileBackendError( 'More than one master backend defined.' ); - } - $this->masterIndex = $index; // this is the "master" - $config['fileJournal'] = $this->fileJournal; // log under proxy backend - } - if ( !empty( $config['readAffinity'] ) ) { - $this->readIndex = $index; // prefer this for reads - } - // Create sub-backend object - if ( !isset( $config['class'] ) ) { - throw new FileBackendError( 'No class given for a backend config.' ); - } - $class = $config['class']; - $this->backends[$index] = new $class( $config ); - } - if ( $this->masterIndex < 0 ) { // need backends and must have a master - throw new FileBackendError( 'No master backend defined.' ); - } - if ( $this->readIndex < 0 ) { - $this->readIndex = $this->masterIndex; // default - } - } - - final protected function doOperationsInternal( array $ops, array $opts ) { - $status = Status::newGood(); - - $mbe = $this->backends[$this->masterIndex]; // convenience - - // Try to lock those files for the scope of this function... - $scopeLock = null; - if ( empty( $opts['nonLocking'] ) ) { - // Try to lock those files for the scope of this function... - /** @noinspection PhpUnusedLocalVariableInspection */ - $scopeLock = $this->getScopedLocksForOps( $ops, $status ); - if ( !$status->isOK() ) { - return $status; // abort - } - } - // Clear any cache entries (after locks acquired) - $this->clearCache(); - $opts['preserveCache'] = true; // only locked files are cached - // Get the list of paths to read/write... - $relevantPaths = $this->fileStoragePathsForOps( $ops ); - // Check if the paths are valid and accessible on all backends... - $status->merge( $this->accessibilityCheck( $relevantPaths ) ); - if ( !$status->isOK() ) { - return $status; // abort - } - // Do a consistency check to see if the backends are consistent... - $syncStatus = $this->consistencyCheck( $relevantPaths ); - if ( !$syncStatus->isOK() ) { - wfDebugLog( 'FileOperation', get_class( $this ) . - " failed sync check: " . FormatJson::encode( $relevantPaths ) ); - // Try to resync the clone backends to the master on the spot... - if ( $this->autoResync === false - || !$this->resyncFiles( $relevantPaths, $this->autoResync )->isOK() - ) { - $status->merge( $syncStatus ); - - return $status; // abort - } - } - // Actually attempt the operation batch on the master backend... - $realOps = $this->substOpBatchPaths( $ops, $mbe ); - $masterStatus = $mbe->doOperations( $realOps, $opts ); - $status->merge( $masterStatus ); - // Propagate the operations to the clone backends if there were no unexpected errors - // and if there were either no expected errors or if the 'force' option was used. - // However, if nothing succeeded at all, then don't replicate any of the operations. - // If $ops only had one operation, this might avoid backend sync inconsistencies. - if ( $masterStatus->isOK() && $masterStatus->successCount > 0 ) { - foreach ( $this->backends as $index => $backend ) { - if ( $index === $this->masterIndex ) { - continue; // done already - } - - $realOps = $this->substOpBatchPaths( $ops, $backend ); - if ( $this->asyncWrites && !$this->hasVolatileSources( $ops ) ) { - // Bind $scopeLock to the callback to preserve locks - DeferredUpdates::addCallableUpdate( - function() use ( $backend, $realOps, $opts, $scopeLock, $relevantPaths ) { - wfDebugLog( 'FileOperationReplication', - "'{$backend->getName()}' async replication; paths: " . - FormatJson::encode( $relevantPaths ) ); - $backend->doOperations( $realOps, $opts ); - } - ); - } else { - wfDebugLog( 'FileOperationReplication', - "'{$backend->getName()}' sync replication; paths: " . - FormatJson::encode( $relevantPaths ) ); - $status->merge( $backend->doOperations( $realOps, $opts ) ); - } - } - } - // Make 'success', 'successCount', and 'failCount' fields reflect - // the overall operation, rather than all the batches for each backend. - // Do this by only using success values from the master backend's batch. - $status->success = $masterStatus->success; - $status->successCount = $masterStatus->successCount; - $status->failCount = $masterStatus->failCount; - - return $status; - } - - /** - * Check that a set of files are consistent across all internal backends - * - * @param array $paths List of storage paths - * @return Status - */ - public function consistencyCheck( array $paths ) { - $status = Status::newGood(); - if ( $this->syncChecks == 0 || count( $this->backends ) <= 1 ) { - return $status; // skip checks - } - - // Preload all of the stat info in as few round trips as possible... - foreach ( $this->backends as $backend ) { - $realPaths = $this->substPaths( $paths, $backend ); - $backend->preloadFileStat( [ 'srcs' => $realPaths, 'latest' => true ] ); - } - - $mBackend = $this->backends[$this->masterIndex]; - foreach ( $paths as $path ) { - $params = [ 'src' => $path, 'latest' => true ]; - $mParams = $this->substOpPaths( $params, $mBackend ); - // Stat the file on the 'master' backend - $mStat = $mBackend->getFileStat( $mParams ); - if ( $this->syncChecks & self::CHECK_SHA1 ) { - $mSha1 = $mBackend->getFileSha1Base36( $mParams ); - } else { - $mSha1 = false; - } - // Check if all clone backends agree with the master... - foreach ( $this->backends as $index => $cBackend ) { - if ( $index === $this->masterIndex ) { - continue; // master - } - $cParams = $this->substOpPaths( $params, $cBackend ); - $cStat = $cBackend->getFileStat( $cParams ); - if ( $mStat ) { // file is in master - if ( !$cStat ) { // file should exist - $status->fatal( 'backend-fail-synced', $path ); - continue; - } - if ( $this->syncChecks & self::CHECK_SIZE ) { - if ( $cStat['size'] != $mStat['size'] ) { // wrong size - $status->fatal( 'backend-fail-synced', $path ); - continue; - } - } - if ( $this->syncChecks & self::CHECK_TIME ) { - $mTs = wfTimestamp( TS_UNIX, $mStat['mtime'] ); - $cTs = wfTimestamp( TS_UNIX, $cStat['mtime'] ); - if ( abs( $mTs - $cTs ) > 30 ) { // outdated file somewhere - $status->fatal( 'backend-fail-synced', $path ); - continue; - } - } - if ( $this->syncChecks & self::CHECK_SHA1 ) { - if ( $cBackend->getFileSha1Base36( $cParams ) !== $mSha1 ) { // wrong SHA1 - $status->fatal( 'backend-fail-synced', $path ); - continue; - } - } - } else { // file is not in master - if ( $cStat ) { // file should not exist - $status->fatal( 'backend-fail-synced', $path ); - } - } - } - } - - return $status; - } - - /** - * Check that a set of file paths are usable across all internal backends - * - * @param array $paths List of storage paths - * @return Status - */ - public function accessibilityCheck( array $paths ) { - $status = Status::newGood(); - if ( count( $this->backends ) <= 1 ) { - return $status; // skip checks - } - - foreach ( $paths as $path ) { - foreach ( $this->backends as $backend ) { - $realPath = $this->substPaths( $path, $backend ); - if ( !$backend->isPathUsableInternal( $realPath ) ) { - $status->fatal( 'backend-fail-usable', $path ); - } - } - } - - return $status; - } - - /** - * Check that a set of files are consistent across all internal backends - * and re-synchronize those files against the "multi master" if needed. - * - * @param array $paths List of storage paths - * @param string|bool $resyncMode False, True, or "conservative"; see __construct() - * @return Status - */ - public function resyncFiles( array $paths, $resyncMode = true ) { - $status = Status::newGood(); - - $mBackend = $this->backends[$this->masterIndex]; - foreach ( $paths as $path ) { - $mPath = $this->substPaths( $path, $mBackend ); - $mSha1 = $mBackend->getFileSha1Base36( [ 'src' => $mPath, 'latest' => true ] ); - $mStat = $mBackend->getFileStat( [ 'src' => $mPath, 'latest' => true ] ); - if ( $mStat === null || ( $mSha1 !== false && !$mStat ) ) { // sanity - $status->fatal( 'backend-fail-internal', $this->name ); - wfDebugLog( 'FileOperation', __METHOD__ - . ': File is not available on the master backend' ); - continue; // file is not available on the master backend... - } - // Check of all clone backends agree with the master... - foreach ( $this->backends as $index => $cBackend ) { - if ( $index === $this->masterIndex ) { - continue; // master - } - $cPath = $this->substPaths( $path, $cBackend ); - $cSha1 = $cBackend->getFileSha1Base36( [ 'src' => $cPath, 'latest' => true ] ); - $cStat = $cBackend->getFileStat( [ 'src' => $cPath, 'latest' => true ] ); - if ( $cStat === null || ( $cSha1 !== false && !$cStat ) ) { // sanity - $status->fatal( 'backend-fail-internal', $cBackend->getName() ); - wfDebugLog( 'FileOperation', __METHOD__ . - ': File is not available on the clone backend' ); - continue; // file is not available on the clone backend... - } - if ( $mSha1 === $cSha1 ) { - // already synced; nothing to do - } elseif ( $mSha1 !== false ) { // file is in master - if ( $resyncMode === 'conservative' - && $cStat && $cStat['mtime'] > $mStat['mtime'] - ) { - $status->fatal( 'backend-fail-synced', $path ); - continue; // don't rollback data - } - $fsFile = $mBackend->getLocalReference( - [ 'src' => $mPath, 'latest' => true ] ); - $status->merge( $cBackend->quickStore( - [ 'src' => $fsFile->getPath(), 'dst' => $cPath ] - ) ); - } elseif ( $mStat === false ) { // file is not in master - if ( $resyncMode === 'conservative' ) { - $status->fatal( 'backend-fail-synced', $path ); - continue; // don't delete data - } - $status->merge( $cBackend->quickDelete( [ 'src' => $cPath ] ) ); - } - } - } - - if ( !$status->isOK() ) { - wfDebugLog( 'FileOperation', get_class( $this ) . - " failed to resync: " . FormatJson::encode( $paths ) ); - } - - return $status; - } - - /** - * Get a list of file storage paths to read or write for a list of operations - * - * @param array $ops Same format as doOperations() - * @return array List of storage paths to files (does not include directories) - */ - protected function fileStoragePathsForOps( array $ops ) { - $paths = []; - foreach ( $ops as $op ) { - if ( isset( $op['src'] ) ) { - // For things like copy/move/delete with "ignoreMissingSource" and there - // is no source file, nothing should happen and there should be no errors. - if ( empty( $op['ignoreMissingSource'] ) - || $this->fileExists( [ 'src' => $op['src'] ] ) - ) { - $paths[] = $op['src']; - } - } - if ( isset( $op['srcs'] ) ) { - $paths = array_merge( $paths, $op['srcs'] ); - } - if ( isset( $op['dst'] ) ) { - $paths[] = $op['dst']; - } - } - - return array_values( array_unique( array_filter( $paths, 'FileBackend::isStoragePath' ) ) ); - } - - /** - * Substitute the backend name in storage path parameters - * for a set of operations with that of a given internal backend. - * - * @param array $ops List of file operation arrays - * @param FileBackendStore $backend - * @return array - */ - protected function substOpBatchPaths( array $ops, FileBackendStore $backend ) { - $newOps = []; // operations - foreach ( $ops as $op ) { - $newOp = $op; // operation - foreach ( [ 'src', 'srcs', 'dst', 'dir' ] as $par ) { - if ( isset( $newOp[$par] ) ) { // string or array - $newOp[$par] = $this->substPaths( $newOp[$par], $backend ); - } - } - $newOps[] = $newOp; - } - - return $newOps; - } - - /** - * Same as substOpBatchPaths() but for a single operation - * - * @param array $ops File operation array - * @param FileBackendStore $backend - * @return array - */ - protected function substOpPaths( array $ops, FileBackendStore $backend ) { - $newOps = $this->substOpBatchPaths( [ $ops ], $backend ); - - return $newOps[0]; - } - - /** - * Substitute the backend of storage paths with an internal backend's name - * - * @param array|string $paths List of paths or single string path - * @param FileBackendStore $backend - * @return array|string - */ - protected function substPaths( $paths, FileBackendStore $backend ) { - return preg_replace( - '!^mwstore://' . preg_quote( $this->name, '!' ) . '/!', - StringUtils::escapeRegexReplacement( "mwstore://{$backend->getName()}/" ), - $paths // string or array - ); - } - - /** - * Substitute the backend of internal storage paths with the proxy backend's name - * - * @param array|string $paths List of paths or single string path - * @return array|string - */ - protected function unsubstPaths( $paths ) { - return preg_replace( - '!^mwstore://([^/]+)!', - StringUtils::escapeRegexReplacement( "mwstore://{$this->name}" ), - $paths // string or array - ); - } - - /** - * @param array $ops File operations for FileBackend::doOperations() - * @return bool Whether there are file path sources with outside lifetime/ownership - */ - protected function hasVolatileSources( array $ops ) { - foreach ( $ops as $op ) { - if ( $op['op'] === 'store' && !isset( $op['srcRef'] ) ) { - return true; // source file might be deleted anytime after do*Operations() - } - } - - return false; - } - - protected function doQuickOperationsInternal( array $ops ) { - $status = Status::newGood(); - // Do the operations on the master backend; setting Status fields... - $realOps = $this->substOpBatchPaths( $ops, $this->backends[$this->masterIndex] ); - $masterStatus = $this->backends[$this->masterIndex]->doQuickOperations( $realOps ); - $status->merge( $masterStatus ); - // Propagate the operations to the clone backends... - foreach ( $this->backends as $index => $backend ) { - if ( $index === $this->masterIndex ) { - continue; // done already - } - - $realOps = $this->substOpBatchPaths( $ops, $backend ); - if ( $this->asyncWrites && !$this->hasVolatileSources( $ops ) ) { - DeferredUpdates::addCallableUpdate( - function() use ( $backend, $realOps ) { - $backend->doQuickOperations( $realOps ); - } - ); - } else { - $status->merge( $backend->doQuickOperations( $realOps ) ); - } - } - // Make 'success', 'successCount', and 'failCount' fields reflect - // the overall operation, rather than all the batches for each backend. - // Do this by only using success values from the master backend's batch. - $status->success = $masterStatus->success; - $status->successCount = $masterStatus->successCount; - $status->failCount = $masterStatus->failCount; - - return $status; - } - - protected function doPrepare( array $params ) { - return $this->doDirectoryOp( 'prepare', $params ); - } - - protected function doSecure( array $params ) { - return $this->doDirectoryOp( 'secure', $params ); - } - - protected function doPublish( array $params ) { - return $this->doDirectoryOp( 'publish', $params ); - } - - protected function doClean( array $params ) { - return $this->doDirectoryOp( 'clean', $params ); - } - - /** - * @param string $method One of (doPrepare,doSecure,doPublish,doClean) - * @param array $params Method arguments - * @return Status - */ - protected function doDirectoryOp( $method, array $params ) { - $status = Status::newGood(); - - $realParams = $this->substOpPaths( $params, $this->backends[$this->masterIndex] ); - $masterStatus = $this->backends[$this->masterIndex]->$method( $realParams ); - $status->merge( $masterStatus ); - - foreach ( $this->backends as $index => $backend ) { - if ( $index === $this->masterIndex ) { - continue; // already done - } - - $realParams = $this->substOpPaths( $params, $backend ); - if ( $this->asyncWrites ) { - DeferredUpdates::addCallableUpdate( - function() use ( $backend, $method, $realParams ) { - $backend->$method( $realParams ); - } - ); - } else { - $status->merge( $backend->$method( $realParams ) ); - } - } - - return $status; - } - - public function concatenate( array $params ) { - // We are writing to an FS file, so we don't need to do this per-backend - $index = $this->getReadIndexFromParams( $params ); - $realParams = $this->substOpPaths( $params, $this->backends[$index] ); - - return $this->backends[$index]->concatenate( $realParams ); - } - - public function fileExists( array $params ) { - $index = $this->getReadIndexFromParams( $params ); - $realParams = $this->substOpPaths( $params, $this->backends[$index] ); - - return $this->backends[$index]->fileExists( $realParams ); - } - - public function getFileTimestamp( array $params ) { - $index = $this->getReadIndexFromParams( $params ); - $realParams = $this->substOpPaths( $params, $this->backends[$index] ); - - return $this->backends[$index]->getFileTimestamp( $realParams ); - } - - public function getFileSize( array $params ) { - $index = $this->getReadIndexFromParams( $params ); - $realParams = $this->substOpPaths( $params, $this->backends[$index] ); - - return $this->backends[$index]->getFileSize( $realParams ); - } - - public function getFileStat( array $params ) { - $index = $this->getReadIndexFromParams( $params ); - $realParams = $this->substOpPaths( $params, $this->backends[$index] ); - - return $this->backends[$index]->getFileStat( $realParams ); - } - - public function getFileXAttributes( array $params ) { - $index = $this->getReadIndexFromParams( $params ); - $realParams = $this->substOpPaths( $params, $this->backends[$index] ); - - return $this->backends[$index]->getFileXAttributes( $realParams ); - } - - public function getFileContentsMulti( array $params ) { - $index = $this->getReadIndexFromParams( $params ); - $realParams = $this->substOpPaths( $params, $this->backends[$index] ); - - $contentsM = $this->backends[$index]->getFileContentsMulti( $realParams ); - - $contents = []; // (path => FSFile) mapping using the proxy backend's name - foreach ( $contentsM as $path => $data ) { - $contents[$this->unsubstPaths( $path )] = $data; - } - - return $contents; - } - - public function getFileSha1Base36( array $params ) { - $index = $this->getReadIndexFromParams( $params ); - $realParams = $this->substOpPaths( $params, $this->backends[$index] ); - - return $this->backends[$index]->getFileSha1Base36( $realParams ); - } - - public function getFileProps( array $params ) { - $index = $this->getReadIndexFromParams( $params ); - $realParams = $this->substOpPaths( $params, $this->backends[$index] ); - - return $this->backends[$index]->getFileProps( $realParams ); - } - - public function streamFile( array $params ) { - $index = $this->getReadIndexFromParams( $params ); - $realParams = $this->substOpPaths( $params, $this->backends[$index] ); - - return $this->backends[$index]->streamFile( $realParams ); - } - - public function getLocalReferenceMulti( array $params ) { - $index = $this->getReadIndexFromParams( $params ); - $realParams = $this->substOpPaths( $params, $this->backends[$index] ); - - $fsFilesM = $this->backends[$index]->getLocalReferenceMulti( $realParams ); - - $fsFiles = []; // (path => FSFile) mapping using the proxy backend's name - foreach ( $fsFilesM as $path => $fsFile ) { - $fsFiles[$this->unsubstPaths( $path )] = $fsFile; - } - - return $fsFiles; - } - - public function getLocalCopyMulti( array $params ) { - $index = $this->getReadIndexFromParams( $params ); - $realParams = $this->substOpPaths( $params, $this->backends[$index] ); - - $tempFilesM = $this->backends[$index]->getLocalCopyMulti( $realParams ); - - $tempFiles = []; // (path => TempFSFile) mapping using the proxy backend's name - foreach ( $tempFilesM as $path => $tempFile ) { - $tempFiles[$this->unsubstPaths( $path )] = $tempFile; - } - - return $tempFiles; - } - - public function getFileHttpUrl( array $params ) { - $index = $this->getReadIndexFromParams( $params ); - $realParams = $this->substOpPaths( $params, $this->backends[$index] ); - - return $this->backends[$index]->getFileHttpUrl( $realParams ); - } - - public function directoryExists( array $params ) { - $realParams = $this->substOpPaths( $params, $this->backends[$this->masterIndex] ); - - return $this->backends[$this->masterIndex]->directoryExists( $realParams ); - } - - public function getDirectoryList( array $params ) { - $realParams = $this->substOpPaths( $params, $this->backends[$this->masterIndex] ); - - return $this->backends[$this->masterIndex]->getDirectoryList( $realParams ); - } - - public function getFileList( array $params ) { - $realParams = $this->substOpPaths( $params, $this->backends[$this->masterIndex] ); - - return $this->backends[$this->masterIndex]->getFileList( $realParams ); - } - - public function getFeatures() { - return $this->backends[$this->masterIndex]->getFeatures(); - } - - public function clearCache( array $paths = null ) { - foreach ( $this->backends as $backend ) { - $realPaths = is_array( $paths ) ? $this->substPaths( $paths, $backend ) : null; - $backend->clearCache( $realPaths ); - } - } - - public function preloadCache( array $paths ) { - $realPaths = $this->substPaths( $paths, $this->backends[$this->readIndex] ); - $this->backends[$this->readIndex]->preloadCache( $realPaths ); - } - - public function preloadFileStat( array $params ) { - $index = $this->getReadIndexFromParams( $params ); - $realParams = $this->substOpPaths( $params, $this->backends[$index] ); - - return $this->backends[$index]->preloadFileStat( $realParams ); - } - - public function getScopedLocksForOps( array $ops, Status $status ) { - $realOps = $this->substOpBatchPaths( $ops, $this->backends[$this->masterIndex] ); - $fileOps = $this->backends[$this->masterIndex]->getOperationsInternal( $realOps ); - // Get the paths to lock from the master backend - $paths = $this->backends[$this->masterIndex]->getPathsToLockForOpsInternal( $fileOps ); - // Get the paths under the proxy backend's name - $pbPaths = [ - LockManager::LOCK_UW => $this->unsubstPaths( $paths[LockManager::LOCK_UW] ), - LockManager::LOCK_EX => $this->unsubstPaths( $paths[LockManager::LOCK_EX] ) - ]; - - // Actually acquire the locks - return $this->getScopedFileLocks( $pbPaths, 'mixed', $status ); - } - - /** - * @param array $params - * @return int The master or read affinity backend index, based on $params['latest'] - */ - protected function getReadIndexFromParams( array $params ) { - return !empty( $params['latest'] ) ? $this->masterIndex : $this->readIndex; - } -} diff --git a/includes/filebackend/FileBackendStore.php b/includes/filebackend/FileBackendStore.php deleted file mode 100644 index 4d9587ef3c..0000000000 --- a/includes/filebackend/FileBackendStore.php +++ /dev/null @@ -1,1971 +0,0 @@ -mimeCallback = isset( $config['mimeCallback'] ) - ? $config['mimeCallback'] - : null; - $this->memCache = WANObjectCache::newEmpty(); // disabled by default - $this->cheapCache = new ProcessCacheLRU( self::CACHE_CHEAP_SIZE ); - $this->expensiveCache = new ProcessCacheLRU( self::CACHE_EXPENSIVE_SIZE ); - } - - /** - * Get the maximum allowable file size given backend - * medium restrictions and basic performance constraints. - * Do not call this function from places outside FileBackend and FileOp. - * - * @return int Bytes - */ - final public function maxFileSizeInternal() { - return $this->maxFileSize; - } - - /** - * Check if a file can be created or changed at a given storage path. - * FS backends should check if the parent directory exists, files can be - * written under it, and that any file already there is writable. - * Backends using key/value stores should check if the container exists. - * - * @param string $storagePath - * @return bool - */ - abstract public function isPathUsableInternal( $storagePath ); - - /** - * Create a file in the backend with the given contents. - * This will overwrite any file that exists at the destination. - * Do not call this function from places outside FileBackend and FileOp. - * - * $params include: - * - content : the raw file contents - * - dst : destination storage path - * - headers : HTTP header name/value map - * - async : Status will be returned immediately if supported. - * If the status is OK, then its value field will be - * set to a FileBackendStoreOpHandle object. - * - dstExists : Whether a file exists at the destination (optimization). - * Callers can use "false" if no existing file is being changed. - * - * @param array $params - * @return Status - */ - final public function createInternal( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - if ( strlen( $params['content'] ) > $this->maxFileSizeInternal() ) { - $status = Status::newFatal( 'backend-fail-maxsize', - $params['dst'], $this->maxFileSizeInternal() ); - } else { - $status = $this->doCreateInternal( $params ); - $this->clearCache( [ $params['dst'] ] ); - if ( !isset( $params['dstExists'] ) || $params['dstExists'] ) { - $this->deleteFileCache( $params['dst'] ); // persistent cache - } - } - - return $status; - } - - /** - * @see FileBackendStore::createInternal() - * @param array $params - * @return Status - */ - abstract protected function doCreateInternal( array $params ); - - /** - * Store a file into the backend from a file on disk. - * This will overwrite any file that exists at the destination. - * Do not call this function from places outside FileBackend and FileOp. - * - * $params include: - * - src : source path on disk - * - dst : destination storage path - * - headers : HTTP header name/value map - * - async : Status will be returned immediately if supported. - * If the status is OK, then its value field will be - * set to a FileBackendStoreOpHandle object. - * - dstExists : Whether a file exists at the destination (optimization). - * Callers can use "false" if no existing file is being changed. - * - * @param array $params - * @return Status - */ - final public function storeInternal( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - if ( filesize( $params['src'] ) > $this->maxFileSizeInternal() ) { - $status = Status::newFatal( 'backend-fail-maxsize', - $params['dst'], $this->maxFileSizeInternal() ); - } else { - $status = $this->doStoreInternal( $params ); - $this->clearCache( [ $params['dst'] ] ); - if ( !isset( $params['dstExists'] ) || $params['dstExists'] ) { - $this->deleteFileCache( $params['dst'] ); // persistent cache - } - } - - return $status; - } - - /** - * @see FileBackendStore::storeInternal() - * @param array $params - * @return Status - */ - abstract protected function doStoreInternal( array $params ); - - /** - * Copy a file from one storage path to another in the backend. - * This will overwrite any file that exists at the destination. - * Do not call this function from places outside FileBackend and FileOp. - * - * $params include: - * - src : source storage path - * - dst : destination storage path - * - ignoreMissingSource : do nothing if the source file does not exist - * - headers : HTTP header name/value map - * - async : Status will be returned immediately if supported. - * If the status is OK, then its value field will be - * set to a FileBackendStoreOpHandle object. - * - dstExists : Whether a file exists at the destination (optimization). - * Callers can use "false" if no existing file is being changed. - * - * @param array $params - * @return Status - */ - final public function copyInternal( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $status = $this->doCopyInternal( $params ); - $this->clearCache( [ $params['dst'] ] ); - if ( !isset( $params['dstExists'] ) || $params['dstExists'] ) { - $this->deleteFileCache( $params['dst'] ); // persistent cache - } - - return $status; - } - - /** - * @see FileBackendStore::copyInternal() - * @param array $params - * @return Status - */ - abstract protected function doCopyInternal( array $params ); - - /** - * Delete a file at the storage path. - * Do not call this function from places outside FileBackend and FileOp. - * - * $params include: - * - src : source storage path - * - ignoreMissingSource : do nothing if the source file does not exist - * - async : Status will be returned immediately if supported. - * If the status is OK, then its value field will be - * set to a FileBackendStoreOpHandle object. - * - * @param array $params - * @return Status - */ - final public function deleteInternal( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $status = $this->doDeleteInternal( $params ); - $this->clearCache( [ $params['src'] ] ); - $this->deleteFileCache( $params['src'] ); // persistent cache - return $status; - } - - /** - * @see FileBackendStore::deleteInternal() - * @param array $params - * @return Status - */ - abstract protected function doDeleteInternal( array $params ); - - /** - * Move a file from one storage path to another in the backend. - * This will overwrite any file that exists at the destination. - * Do not call this function from places outside FileBackend and FileOp. - * - * $params include: - * - src : source storage path - * - dst : destination storage path - * - ignoreMissingSource : do nothing if the source file does not exist - * - headers : HTTP header name/value map - * - async : Status will be returned immediately if supported. - * If the status is OK, then its value field will be - * set to a FileBackendStoreOpHandle object. - * - dstExists : Whether a file exists at the destination (optimization). - * Callers can use "false" if no existing file is being changed. - * - * @param array $params - * @return Status - */ - final public function moveInternal( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $status = $this->doMoveInternal( $params ); - $this->clearCache( [ $params['src'], $params['dst'] ] ); - $this->deleteFileCache( $params['src'] ); // persistent cache - if ( !isset( $params['dstExists'] ) || $params['dstExists'] ) { - $this->deleteFileCache( $params['dst'] ); // persistent cache - } - - return $status; - } - - /** - * @see FileBackendStore::moveInternal() - * @param array $params - * @return Status - */ - protected function doMoveInternal( array $params ) { - unset( $params['async'] ); // two steps, won't work here :) - $nsrc = FileBackend::normalizeStoragePath( $params['src'] ); - $ndst = FileBackend::normalizeStoragePath( $params['dst'] ); - // Copy source to dest - $status = $this->copyInternal( $params ); - if ( $nsrc !== $ndst && $status->isOK() ) { - // Delete source (only fails due to races or network problems) - $status->merge( $this->deleteInternal( [ 'src' => $params['src'] ] ) ); - $status->setResult( true, $status->value ); // ignore delete() errors - } - - return $status; - } - - /** - * Alter metadata for a file at the storage path. - * Do not call this function from places outside FileBackend and FileOp. - * - * $params include: - * - src : source storage path - * - headers : HTTP header name/value map - * - async : Status will be returned immediately if supported. - * If the status is OK, then its value field will be - * set to a FileBackendStoreOpHandle object. - * - * @param array $params - * @return Status - */ - final public function describeInternal( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - if ( count( $params['headers'] ) ) { - $status = $this->doDescribeInternal( $params ); - $this->clearCache( [ $params['src'] ] ); - $this->deleteFileCache( $params['src'] ); // persistent cache - } else { - $status = Status::newGood(); // nothing to do - } - - return $status; - } - - /** - * @see FileBackendStore::describeInternal() - * @param array $params - * @return Status - */ - protected function doDescribeInternal( array $params ) { - return Status::newGood(); - } - - /** - * No-op file operation that does nothing. - * Do not call this function from places outside FileBackend and FileOp. - * - * @param array $params - * @return Status - */ - final public function nullInternal( array $params ) { - return Status::newGood(); - } - - final public function concatenate( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $status = Status::newGood(); - - // Try to lock the source files for the scope of this function - $scopeLockS = $this->getScopedFileLocks( $params['srcs'], LockManager::LOCK_UW, $status ); - if ( $status->isOK() ) { - // Actually do the file concatenation... - $start_time = microtime( true ); - $status->merge( $this->doConcatenate( $params ) ); - $sec = microtime( true ) - $start_time; - if ( !$status->isOK() ) { - wfDebugLog( 'FileOperation', get_class( $this ) . "-{$this->name}" . - " failed to concatenate " . count( $params['srcs'] ) . " file(s) [$sec sec]" ); - } - } - - return $status; - } - - /** - * @see FileBackendStore::concatenate() - * @param array $params - * @return Status - */ - protected function doConcatenate( array $params ) { - $status = Status::newGood(); - $tmpPath = $params['dst']; // convenience - unset( $params['latest'] ); // sanity - - // Check that the specified temp file is valid... - MediaWiki\suppressWarnings(); - $ok = ( is_file( $tmpPath ) && filesize( $tmpPath ) == 0 ); - MediaWiki\restoreWarnings(); - if ( !$ok ) { // not present or not empty - $status->fatal( 'backend-fail-opentemp', $tmpPath ); - - return $status; - } - - // Get local FS versions of the chunks needed for the concatenation... - $fsFiles = $this->getLocalReferenceMulti( $params ); - foreach ( $fsFiles as $path => &$fsFile ) { - if ( !$fsFile ) { // chunk failed to download? - $fsFile = $this->getLocalReference( [ 'src' => $path ] ); - if ( !$fsFile ) { // retry failed? - $status->fatal( 'backend-fail-read', $path ); - - return $status; - } - } - } - unset( $fsFile ); // unset reference so we can reuse $fsFile - - // Get a handle for the destination temp file - $tmpHandle = fopen( $tmpPath, 'ab' ); - if ( $tmpHandle === false ) { - $status->fatal( 'backend-fail-opentemp', $tmpPath ); - - return $status; - } - - // Build up the temp file using the source chunks (in order)... - foreach ( $fsFiles as $virtualSource => $fsFile ) { - // Get a handle to the local FS version - $sourceHandle = fopen( $fsFile->getPath(), 'rb' ); - if ( $sourceHandle === false ) { - fclose( $tmpHandle ); - $status->fatal( 'backend-fail-read', $virtualSource ); - - return $status; - } - // Append chunk to file (pass chunk size to avoid magic quotes) - if ( !stream_copy_to_stream( $sourceHandle, $tmpHandle ) ) { - fclose( $sourceHandle ); - fclose( $tmpHandle ); - $status->fatal( 'backend-fail-writetemp', $tmpPath ); - - return $status; - } - fclose( $sourceHandle ); - } - if ( !fclose( $tmpHandle ) ) { - $status->fatal( 'backend-fail-closetemp', $tmpPath ); - - return $status; - } - - clearstatcache(); // temp file changed - - return $status; - } - - final protected function doPrepare( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $status = Status::newGood(); - - list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] ); - if ( $dir === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['dir'] ); - - return $status; // invalid storage path - } - - if ( $shard !== null ) { // confined to a single container/shard - $status->merge( $this->doPrepareInternal( $fullCont, $dir, $params ) ); - } else { // directory is on several shards - wfDebug( __METHOD__ . ": iterating over all container shards.\n" ); - list( , $shortCont, ) = self::splitStoragePath( $params['dir'] ); - foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) { - $status->merge( $this->doPrepareInternal( "{$fullCont}{$suffix}", $dir, $params ) ); - } - } - - return $status; - } - - /** - * @see FileBackendStore::doPrepare() - * @param string $container - * @param string $dir - * @param array $params - * @return Status - */ - protected function doPrepareInternal( $container, $dir, array $params ) { - return Status::newGood(); - } - - final protected function doSecure( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $status = Status::newGood(); - - list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] ); - if ( $dir === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['dir'] ); - - return $status; // invalid storage path - } - - if ( $shard !== null ) { // confined to a single container/shard - $status->merge( $this->doSecureInternal( $fullCont, $dir, $params ) ); - } else { // directory is on several shards - wfDebug( __METHOD__ . ": iterating over all container shards.\n" ); - list( , $shortCont, ) = self::splitStoragePath( $params['dir'] ); - foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) { - $status->merge( $this->doSecureInternal( "{$fullCont}{$suffix}", $dir, $params ) ); - } - } - - return $status; - } - - /** - * @see FileBackendStore::doSecure() - * @param string $container - * @param string $dir - * @param array $params - * @return Status - */ - protected function doSecureInternal( $container, $dir, array $params ) { - return Status::newGood(); - } - - final protected function doPublish( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $status = Status::newGood(); - - list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] ); - if ( $dir === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['dir'] ); - - return $status; // invalid storage path - } - - if ( $shard !== null ) { // confined to a single container/shard - $status->merge( $this->doPublishInternal( $fullCont, $dir, $params ) ); - } else { // directory is on several shards - wfDebug( __METHOD__ . ": iterating over all container shards.\n" ); - list( , $shortCont, ) = self::splitStoragePath( $params['dir'] ); - foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) { - $status->merge( $this->doPublishInternal( "{$fullCont}{$suffix}", $dir, $params ) ); - } - } - - return $status; - } - - /** - * @see FileBackendStore::doPublish() - * @param string $container - * @param string $dir - * @param array $params - * @return Status - */ - protected function doPublishInternal( $container, $dir, array $params ) { - return Status::newGood(); - } - - final protected function doClean( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $status = Status::newGood(); - - // Recursive: first delete all empty subdirs recursively - if ( !empty( $params['recursive'] ) && !$this->directoriesAreVirtual() ) { - $subDirsRel = $this->getTopDirectoryList( [ 'dir' => $params['dir'] ] ); - if ( $subDirsRel !== null ) { // no errors - foreach ( $subDirsRel as $subDirRel ) { - $subDir = $params['dir'] . "/{$subDirRel}"; // full path - $status->merge( $this->doClean( [ 'dir' => $subDir ] + $params ) ); - } - unset( $subDirsRel ); // free directory for rmdir() on Windows (for FS backends) - } - } - - list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] ); - if ( $dir === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['dir'] ); - - return $status; // invalid storage path - } - - // Attempt to lock this directory... - $filesLockEx = [ $params['dir'] ]; - $scopedLockE = $this->getScopedFileLocks( $filesLockEx, LockManager::LOCK_EX, $status ); - if ( !$status->isOK() ) { - return $status; // abort - } - - if ( $shard !== null ) { // confined to a single container/shard - $status->merge( $this->doCleanInternal( $fullCont, $dir, $params ) ); - $this->deleteContainerCache( $fullCont ); // purge cache - } else { // directory is on several shards - wfDebug( __METHOD__ . ": iterating over all container shards.\n" ); - list( , $shortCont, ) = self::splitStoragePath( $params['dir'] ); - foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) { - $status->merge( $this->doCleanInternal( "{$fullCont}{$suffix}", $dir, $params ) ); - $this->deleteContainerCache( "{$fullCont}{$suffix}" ); // purge cache - } - } - - return $status; - } - - /** - * @see FileBackendStore::doClean() - * @param string $container - * @param string $dir - * @param array $params - * @return Status - */ - protected function doCleanInternal( $container, $dir, array $params ) { - return Status::newGood(); - } - - final public function fileExists( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $stat = $this->getFileStat( $params ); - - return ( $stat === null ) ? null : (bool)$stat; // null => failure - } - - final public function getFileTimestamp( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $stat = $this->getFileStat( $params ); - - return $stat ? $stat['mtime'] : false; - } - - final public function getFileSize( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $stat = $this->getFileStat( $params ); - - return $stat ? $stat['size'] : false; - } - - final public function getFileStat( array $params ) { - $path = self::normalizeStoragePath( $params['src'] ); - if ( $path === null ) { - return false; // invalid storage path - } - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $latest = !empty( $params['latest'] ); // use latest data? - if ( !$latest && !$this->cheapCache->has( $path, 'stat', self::CACHE_TTL ) ) { - $this->primeFileCache( [ $path ] ); // check persistent cache - } - if ( $this->cheapCache->has( $path, 'stat', self::CACHE_TTL ) ) { - $stat = $this->cheapCache->get( $path, 'stat' ); - // If we want the latest data, check that this cached - // value was in fact fetched with the latest available data. - if ( is_array( $stat ) ) { - if ( !$latest || $stat['latest'] ) { - return $stat; - } - } elseif ( in_array( $stat, [ 'NOT_EXIST', 'NOT_EXIST_LATEST' ] ) ) { - if ( !$latest || $stat === 'NOT_EXIST_LATEST' ) { - return false; - } - } - } - $stat = $this->doGetFileStat( $params ); - if ( is_array( $stat ) ) { // file exists - // Strongly consistent backends can automatically set "latest" - $stat['latest'] = isset( $stat['latest'] ) ? $stat['latest'] : $latest; - $this->cheapCache->set( $path, 'stat', $stat ); - $this->setFileCache( $path, $stat ); // update persistent cache - if ( isset( $stat['sha1'] ) ) { // some backends store SHA-1 as metadata - $this->cheapCache->set( $path, 'sha1', - [ 'hash' => $stat['sha1'], 'latest' => $latest ] ); - } - if ( isset( $stat['xattr'] ) ) { // some backends store headers/metadata - $stat['xattr'] = self::normalizeXAttributes( $stat['xattr'] ); - $this->cheapCache->set( $path, 'xattr', - [ 'map' => $stat['xattr'], 'latest' => $latest ] ); - } - } elseif ( $stat === false ) { // file does not exist - $this->cheapCache->set( $path, 'stat', $latest ? 'NOT_EXIST_LATEST' : 'NOT_EXIST' ); - $this->cheapCache->set( $path, 'xattr', [ 'map' => false, 'latest' => $latest ] ); - $this->cheapCache->set( $path, 'sha1', [ 'hash' => false, 'latest' => $latest ] ); - wfDebug( __METHOD__ . ": File $path does not exist.\n" ); - } else { // an error occurred - wfDebug( __METHOD__ . ": Could not stat file $path.\n" ); - } - - return $stat; - } - - /** - * @see FileBackendStore::getFileStat() - */ - abstract protected function doGetFileStat( array $params ); - - public function getFileContentsMulti( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - - $params = $this->setConcurrencyFlags( $params ); - $contents = $this->doGetFileContentsMulti( $params ); - - return $contents; - } - - /** - * @see FileBackendStore::getFileContentsMulti() - * @param array $params - * @return array - */ - protected function doGetFileContentsMulti( array $params ) { - $contents = []; - foreach ( $this->doGetLocalReferenceMulti( $params ) as $path => $fsFile ) { - MediaWiki\suppressWarnings(); - $contents[$path] = $fsFile ? file_get_contents( $fsFile->getPath() ) : false; - MediaWiki\restoreWarnings(); - } - - return $contents; - } - - final public function getFileXAttributes( array $params ) { - $path = self::normalizeStoragePath( $params['src'] ); - if ( $path === null ) { - return false; // invalid storage path - } - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $latest = !empty( $params['latest'] ); // use latest data? - if ( $this->cheapCache->has( $path, 'xattr', self::CACHE_TTL ) ) { - $stat = $this->cheapCache->get( $path, 'xattr' ); - // If we want the latest data, check that this cached - // value was in fact fetched with the latest available data. - if ( !$latest || $stat['latest'] ) { - return $stat['map']; - } - } - $fields = $this->doGetFileXAttributes( $params ); - $fields = is_array( $fields ) ? self::normalizeXAttributes( $fields ) : false; - $this->cheapCache->set( $path, 'xattr', [ 'map' => $fields, 'latest' => $latest ] ); - - return $fields; - } - - /** - * @see FileBackendStore::getFileXAttributes() - * @return bool|string - */ - protected function doGetFileXAttributes( array $params ) { - return [ 'headers' => [], 'metadata' => [] ]; // not supported - } - - final public function getFileSha1Base36( array $params ) { - $path = self::normalizeStoragePath( $params['src'] ); - if ( $path === null ) { - return false; // invalid storage path - } - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $latest = !empty( $params['latest'] ); // use latest data? - if ( $this->cheapCache->has( $path, 'sha1', self::CACHE_TTL ) ) { - $stat = $this->cheapCache->get( $path, 'sha1' ); - // If we want the latest data, check that this cached - // value was in fact fetched with the latest available data. - if ( !$latest || $stat['latest'] ) { - return $stat['hash']; - } - } - $hash = $this->doGetFileSha1Base36( $params ); - $this->cheapCache->set( $path, 'sha1', [ 'hash' => $hash, 'latest' => $latest ] ); - - return $hash; - } - - /** - * @see FileBackendStore::getFileSha1Base36() - * @param array $params - * @return bool|string - */ - protected function doGetFileSha1Base36( array $params ) { - $fsFile = $this->getLocalReference( $params ); - if ( !$fsFile ) { - return false; - } else { - return $fsFile->getSha1Base36(); - } - } - - final public function getFileProps( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $fsFile = $this->getLocalReference( $params ); - $props = $fsFile ? $fsFile->getProps() : FSFile::placeholderProps(); - - return $props; - } - - final public function getLocalReferenceMulti( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - - $params = $this->setConcurrencyFlags( $params ); - - $fsFiles = []; // (path => FSFile) - $latest = !empty( $params['latest'] ); // use latest data? - // Reuse any files already in process cache... - foreach ( $params['srcs'] as $src ) { - $path = self::normalizeStoragePath( $src ); - if ( $path === null ) { - $fsFiles[$src] = null; // invalid storage path - } elseif ( $this->expensiveCache->has( $path, 'localRef' ) ) { - $val = $this->expensiveCache->get( $path, 'localRef' ); - // If we want the latest data, check that this cached - // value was in fact fetched with the latest available data. - if ( !$latest || $val['latest'] ) { - $fsFiles[$src] = $val['object']; - } - } - } - // Fetch local references of any remaning files... - $params['srcs'] = array_diff( $params['srcs'], array_keys( $fsFiles ) ); - foreach ( $this->doGetLocalReferenceMulti( $params ) as $path => $fsFile ) { - $fsFiles[$path] = $fsFile; - if ( $fsFile ) { // update the process cache... - $this->expensiveCache->set( $path, 'localRef', - [ 'object' => $fsFile, 'latest' => $latest ] ); - } - } - - return $fsFiles; - } - - /** - * @see FileBackendStore::getLocalReferenceMulti() - * @param array $params - * @return array - */ - protected function doGetLocalReferenceMulti( array $params ) { - return $this->doGetLocalCopyMulti( $params ); - } - - final public function getLocalCopyMulti( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - - $params = $this->setConcurrencyFlags( $params ); - $tmpFiles = $this->doGetLocalCopyMulti( $params ); - - return $tmpFiles; - } - - /** - * @see FileBackendStore::getLocalCopyMulti() - * @param array $params - * @return array - */ - abstract protected function doGetLocalCopyMulti( array $params ); - - /** - * @see FileBackend::getFileHttpUrl() - * @param array $params - * @return string|null - */ - public function getFileHttpUrl( array $params ) { - return null; // not supported - } - - final public function streamFile( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $status = Status::newGood(); - - $info = $this->getFileStat( $params ); - if ( !$info ) { // let StreamFile handle the 404 - $status->fatal( 'backend-fail-notexists', $params['src'] ); - } - - // Set output buffer and HTTP headers for stream - $extraHeaders = isset( $params['headers'] ) ? $params['headers'] : []; - $res = StreamFile::prepareForStream( $params['src'], $info, $extraHeaders ); - if ( $res == StreamFile::NOT_MODIFIED ) { - // do nothing; client cache is up to date - } elseif ( $res == StreamFile::READY_STREAM ) { - $status = $this->doStreamFile( $params ); - if ( !$status->isOK() ) { - // Per bug 41113, nasty things can happen if bad cache entries get - // stuck in cache. It's also possible that this error can come up - // with simple race conditions. Clear out the stat cache to be safe. - $this->clearCache( [ $params['src'] ] ); - $this->deleteFileCache( $params['src'] ); - trigger_error( "Bad stat cache or race condition for file {$params['src']}." ); - } - } else { - $status->fatal( 'backend-fail-stream', $params['src'] ); - } - - return $status; - } - - /** - * @see FileBackendStore::streamFile() - * @param array $params - * @return Status - */ - protected function doStreamFile( array $params ) { - $status = Status::newGood(); - - $fsFile = $this->getLocalReference( $params ); - if ( !$fsFile ) { - $status->fatal( 'backend-fail-stream', $params['src'] ); - } elseif ( !readfile( $fsFile->getPath() ) ) { - $status->fatal( 'backend-fail-stream', $params['src'] ); - } - - return $status; - } - - final public function directoryExists( array $params ) { - list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] ); - if ( $dir === null ) { - return false; // invalid storage path - } - if ( $shard !== null ) { // confined to a single container/shard - return $this->doDirectoryExists( $fullCont, $dir, $params ); - } else { // directory is on several shards - wfDebug( __METHOD__ . ": iterating over all container shards.\n" ); - list( , $shortCont, ) = self::splitStoragePath( $params['dir'] ); - $res = false; // response - foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) { - $exists = $this->doDirectoryExists( "{$fullCont}{$suffix}", $dir, $params ); - if ( $exists ) { - $res = true; - break; // found one! - } elseif ( $exists === null ) { // error? - $res = null; // if we don't find anything, it is indeterminate - } - } - - return $res; - } - } - - /** - * @see FileBackendStore::directoryExists() - * - * @param string $container Resolved container name - * @param string $dir Resolved path relative to container - * @param array $params - * @return bool|null - */ - abstract protected function doDirectoryExists( $container, $dir, array $params ); - - final public function getDirectoryList( array $params ) { - list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] ); - if ( $dir === null ) { // invalid storage path - return null; - } - if ( $shard !== null ) { - // File listing is confined to a single container/shard - return $this->getDirectoryListInternal( $fullCont, $dir, $params ); - } else { - wfDebug( __METHOD__ . ": iterating over all container shards.\n" ); - // File listing spans multiple containers/shards - list( , $shortCont, ) = self::splitStoragePath( $params['dir'] ); - - return new FileBackendStoreShardDirIterator( $this, - $fullCont, $dir, $this->getContainerSuffixes( $shortCont ), $params ); - } - } - - /** - * Do not call this function from places outside FileBackend - * - * @see FileBackendStore::getDirectoryList() - * - * @param string $container Resolved container name - * @param string $dir Resolved path relative to container - * @param array $params - * @return Traversable|array|null Returns null on failure - */ - abstract public function getDirectoryListInternal( $container, $dir, array $params ); - - final public function getFileList( array $params ) { - list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] ); - if ( $dir === null ) { // invalid storage path - return null; - } - if ( $shard !== null ) { - // File listing is confined to a single container/shard - return $this->getFileListInternal( $fullCont, $dir, $params ); - } else { - wfDebug( __METHOD__ . ": iterating over all container shards.\n" ); - // File listing spans multiple containers/shards - list( , $shortCont, ) = self::splitStoragePath( $params['dir'] ); - - return new FileBackendStoreShardFileIterator( $this, - $fullCont, $dir, $this->getContainerSuffixes( $shortCont ), $params ); - } - } - - /** - * Do not call this function from places outside FileBackend - * - * @see FileBackendStore::getFileList() - * - * @param string $container Resolved container name - * @param string $dir Resolved path relative to container - * @param array $params - * @return Traversable|array|null Returns null on failure - */ - abstract public function getFileListInternal( $container, $dir, array $params ); - - /** - * Return a list of FileOp objects from a list of operations. - * Do not call this function from places outside FileBackend. - * - * The result must have the same number of items as the input. - * An exception is thrown if an unsupported operation is requested. - * - * @param array $ops Same format as doOperations() - * @return array List of FileOp objects - * @throws FileBackendError - */ - final public function getOperationsInternal( array $ops ) { - $supportedOps = [ - 'store' => 'StoreFileOp', - 'copy' => 'CopyFileOp', - 'move' => 'MoveFileOp', - 'delete' => 'DeleteFileOp', - 'create' => 'CreateFileOp', - 'describe' => 'DescribeFileOp', - 'null' => 'NullFileOp' - ]; - - $performOps = []; // array of FileOp objects - // Build up ordered array of FileOps... - foreach ( $ops as $operation ) { - $opName = $operation['op']; - if ( isset( $supportedOps[$opName] ) ) { - $class = $supportedOps[$opName]; - // Get params for this operation - $params = $operation; - // Append the FileOp class - $performOps[] = new $class( $this, $params ); - } else { - throw new FileBackendError( "Operation '$opName' is not supported." ); - } - } - - return $performOps; - } - - /** - * Get a list of storage paths to lock for a list of operations - * Returns an array with LockManager::LOCK_UW (shared locks) and - * LockManager::LOCK_EX (exclusive locks) keys, each corresponding - * to a list of storage paths to be locked. All returned paths are - * normalized. - * - * @param array $performOps List of FileOp objects - * @return array (LockManager::LOCK_UW => path list, LockManager::LOCK_EX => path list) - */ - final public function getPathsToLockForOpsInternal( array $performOps ) { - // Build up a list of files to lock... - $paths = [ 'sh' => [], 'ex' => [] ]; - foreach ( $performOps as $fileOp ) { - $paths['sh'] = array_merge( $paths['sh'], $fileOp->storagePathsRead() ); - $paths['ex'] = array_merge( $paths['ex'], $fileOp->storagePathsChanged() ); - } - // Optimization: if doing an EX lock anyway, don't also set an SH one - $paths['sh'] = array_diff( $paths['sh'], $paths['ex'] ); - // Get a shared lock on the parent directory of each path changed - $paths['sh'] = array_merge( $paths['sh'], array_map( 'dirname', $paths['ex'] ) ); - - return [ - LockManager::LOCK_UW => $paths['sh'], - LockManager::LOCK_EX => $paths['ex'] - ]; - } - - public function getScopedLocksForOps( array $ops, Status $status ) { - $paths = $this->getPathsToLockForOpsInternal( $this->getOperationsInternal( $ops ) ); - - return $this->getScopedFileLocks( $paths, 'mixed', $status ); - } - - final protected function doOperationsInternal( array $ops, array $opts ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $status = Status::newGood(); - - // Fix up custom header name/value pairs... - $ops = array_map( [ $this, 'sanitizeOpHeaders' ], $ops ); - - // Build up a list of FileOps... - $performOps = $this->getOperationsInternal( $ops ); - - // Acquire any locks as needed... - if ( empty( $opts['nonLocking'] ) ) { - // Build up a list of files to lock... - $paths = $this->getPathsToLockForOpsInternal( $performOps ); - // Try to lock those files for the scope of this function... - - $scopeLock = $this->getScopedFileLocks( $paths, 'mixed', $status ); - if ( !$status->isOK() ) { - return $status; // abort - } - } - - // Clear any file cache entries (after locks acquired) - if ( empty( $opts['preserveCache'] ) ) { - $this->clearCache(); - } - - // Build the list of paths involved - $paths = []; - foreach ( $performOps as $op ) { - $paths = array_merge( $paths, $op->storagePathsRead() ); - $paths = array_merge( $paths, $op->storagePathsChanged() ); - } - - // Enlarge the cache to fit the stat entries of these files - $this->cheapCache->resize( max( 2 * count( $paths ), self::CACHE_CHEAP_SIZE ) ); - - // Load from the persistent container caches - $this->primeContainerCache( $paths ); - // Get the latest stat info for all the files (having locked them) - $ok = $this->preloadFileStat( [ 'srcs' => $paths, 'latest' => true ] ); - - if ( $ok ) { - // Actually attempt the operation batch... - $opts = $this->setConcurrencyFlags( $opts ); - $subStatus = FileOpBatch::attempt( $performOps, $opts, $this->fileJournal ); - } else { - // If we could not even stat some files, then bail out... - $subStatus = Status::newFatal( 'backend-fail-internal', $this->name ); - foreach ( $ops as $i => $op ) { // mark each op as failed - $subStatus->success[$i] = false; - ++$subStatus->failCount; - } - wfDebugLog( 'FileOperation', get_class( $this ) . "-{$this->name} " . - " stat failure; aborted operations: " . FormatJson::encode( $ops ) ); - } - - // Merge errors into status fields - $status->merge( $subStatus ); - $status->success = $subStatus->success; // not done in merge() - - // Shrink the stat cache back to normal size - $this->cheapCache->resize( self::CACHE_CHEAP_SIZE ); - - return $status; - } - - final protected function doQuickOperationsInternal( array $ops ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $status = Status::newGood(); - - // Fix up custom header name/value pairs... - $ops = array_map( [ $this, 'sanitizeOpHeaders' ], $ops ); - - // Clear any file cache entries - $this->clearCache(); - - $supportedOps = [ 'create', 'store', 'copy', 'move', 'delete', 'describe', 'null' ]; - // Parallel ops may be disabled in config due to dependencies (e.g. needing popen()) - $async = ( $this->parallelize === 'implicit' && count( $ops ) > 1 ); - $maxConcurrency = $this->concurrency; // throttle - - $statuses = []; // array of (index => Status) - $fileOpHandles = []; // list of (index => handle) arrays - $curFileOpHandles = []; // current handle batch - // Perform the sync-only ops and build up op handles for the async ops... - foreach ( $ops as $index => $params ) { - if ( !in_array( $params['op'], $supportedOps ) ) { - throw new FileBackendError( "Operation '{$params['op']}' is not supported." ); - } - $method = $params['op'] . 'Internal'; // e.g. "storeInternal" - $subStatus = $this->$method( [ 'async' => $async ] + $params ); - if ( $subStatus->value instanceof FileBackendStoreOpHandle ) { // async - if ( count( $curFileOpHandles ) >= $maxConcurrency ) { - $fileOpHandles[] = $curFileOpHandles; // push this batch - $curFileOpHandles = []; - } - $curFileOpHandles[$index] = $subStatus->value; // keep index - } else { // error or completed - $statuses[$index] = $subStatus; // keep index - } - } - if ( count( $curFileOpHandles ) ) { - $fileOpHandles[] = $curFileOpHandles; // last batch - } - // Do all the async ops that can be done concurrently... - foreach ( $fileOpHandles as $fileHandleBatch ) { - $statuses = $statuses + $this->executeOpHandlesInternal( $fileHandleBatch ); - } - // Marshall and merge all the responses... - foreach ( $statuses as $index => $subStatus ) { - $status->merge( $subStatus ); - if ( $subStatus->isOK() ) { - $status->success[$index] = true; - ++$status->successCount; - } else { - $status->success[$index] = false; - ++$status->failCount; - } - } - - return $status; - } - - /** - * Execute a list of FileBackendStoreOpHandle handles in parallel. - * The resulting Status object fields will correspond - * to the order in which the handles where given. - * - * @param FileBackendStoreOpHandle[] $fileOpHandles - * - * @throws FileBackendError - * @return array Map of Status objects - */ - final public function executeOpHandlesInternal( array $fileOpHandles ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - - foreach ( $fileOpHandles as $fileOpHandle ) { - if ( !( $fileOpHandle instanceof FileBackendStoreOpHandle ) ) { - throw new FileBackendError( "Given a non-FileBackendStoreOpHandle object." ); - } elseif ( $fileOpHandle->backend->getName() !== $this->getName() ) { - throw new FileBackendError( "Given a FileBackendStoreOpHandle for the wrong backend." ); - } - } - $res = $this->doExecuteOpHandlesInternal( $fileOpHandles ); - foreach ( $fileOpHandles as $fileOpHandle ) { - $fileOpHandle->closeResources(); - } - - return $res; - } - - /** - * @see FileBackendStore::executeOpHandlesInternal() - * - * @param FileBackendStoreOpHandle[] $fileOpHandles - * - * @throws FileBackendError - * @return Status[] List of corresponding Status objects - */ - protected function doExecuteOpHandlesInternal( array $fileOpHandles ) { - if ( count( $fileOpHandles ) ) { - throw new FileBackendError( "This backend supports no asynchronous operations." ); - } - - return []; - } - - /** - * Normalize and filter HTTP headers from a file operation - * - * This normalizes and strips long HTTP headers from a file operation. - * Most headers are just numbers, but some are allowed to be long. - * This function is useful for cleaning up headers and avoiding backend - * specific errors, especially in the middle of batch file operations. - * - * @param array $op Same format as doOperation() - * @return array - */ - protected function sanitizeOpHeaders( array $op ) { - static $longs = [ 'content-disposition' ]; - - if ( isset( $op['headers'] ) ) { // op sets HTTP headers - $newHeaders = []; - foreach ( $op['headers'] as $name => $value ) { - $name = strtolower( $name ); - $maxHVLen = in_array( $name, $longs ) ? INF : 255; - if ( strlen( $name ) > 255 || strlen( $value ) > $maxHVLen ) { - trigger_error( "Header '$name: $value' is too long." ); - } else { - $newHeaders[$name] = strlen( $value ) ? $value : ''; // null/false => "" - } - } - $op['headers'] = $newHeaders; - } - - return $op; - } - - final public function preloadCache( array $paths ) { - $fullConts = []; // full container names - foreach ( $paths as $path ) { - list( $fullCont, , ) = $this->resolveStoragePath( $path ); - $fullConts[] = $fullCont; - } - // Load from the persistent file and container caches - $this->primeContainerCache( $fullConts ); - $this->primeFileCache( $paths ); - } - - final public function clearCache( array $paths = null ) { - if ( is_array( $paths ) ) { - $paths = array_map( 'FileBackend::normalizeStoragePath', $paths ); - $paths = array_filter( $paths, 'strlen' ); // remove nulls - } - if ( $paths === null ) { - $this->cheapCache->clear(); - $this->expensiveCache->clear(); - } else { - foreach ( $paths as $path ) { - $this->cheapCache->clear( $path ); - $this->expensiveCache->clear( $path ); - } - } - $this->doClearCache( $paths ); - } - - /** - * Clears any additional stat caches for storage paths - * - * @see FileBackend::clearCache() - * - * @param array $paths Storage paths (optional) - */ - protected function doClearCache( array $paths = null ) { - } - - final public function preloadFileStat( array $params ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - $success = true; // no network errors - - $params['concurrency'] = ( $this->parallelize !== 'off' ) ? $this->concurrency : 1; - $stats = $this->doGetFileStatMulti( $params ); - if ( $stats === null ) { - return true; // not supported - } - - $latest = !empty( $params['latest'] ); // use latest data? - foreach ( $stats as $path => $stat ) { - $path = FileBackend::normalizeStoragePath( $path ); - if ( $path === null ) { - continue; // this shouldn't happen - } - if ( is_array( $stat ) ) { // file exists - // Strongly consistent backends can automatically set "latest" - $stat['latest'] = isset( $stat['latest'] ) ? $stat['latest'] : $latest; - $this->cheapCache->set( $path, 'stat', $stat ); - $this->setFileCache( $path, $stat ); // update persistent cache - if ( isset( $stat['sha1'] ) ) { // some backends store SHA-1 as metadata - $this->cheapCache->set( $path, 'sha1', - [ 'hash' => $stat['sha1'], 'latest' => $latest ] ); - } - if ( isset( $stat['xattr'] ) ) { // some backends store headers/metadata - $stat['xattr'] = self::normalizeXAttributes( $stat['xattr'] ); - $this->cheapCache->set( $path, 'xattr', - [ 'map' => $stat['xattr'], 'latest' => $latest ] ); - } - } elseif ( $stat === false ) { // file does not exist - $this->cheapCache->set( $path, 'stat', - $latest ? 'NOT_EXIST_LATEST' : 'NOT_EXIST' ); - $this->cheapCache->set( $path, 'xattr', - [ 'map' => false, 'latest' => $latest ] ); - $this->cheapCache->set( $path, 'sha1', - [ 'hash' => false, 'latest' => $latest ] ); - wfDebug( __METHOD__ . ": File $path does not exist.\n" ); - } else { // an error occurred - $success = false; - wfDebug( __METHOD__ . ": Could not stat file $path.\n" ); - } - } - - return $success; - } - - /** - * Get file stat information (concurrently if possible) for several files - * - * @see FileBackend::getFileStat() - * - * @param array $params Parameters include: - * - srcs : list of source storage paths - * - latest : use the latest available data - * @return array|null Map of storage paths to array|bool|null (returns null if not supported) - * @since 1.23 - */ - protected function doGetFileStatMulti( array $params ) { - return null; // not supported - } - - /** - * Is this a key/value store where directories are just virtual? - * Virtual directories exists in so much as files exists that are - * prefixed with the directory path followed by a forward slash. - * - * @return bool - */ - abstract protected function directoriesAreVirtual(); - - /** - * Check if a short container name is valid - * - * This checks for length and illegal characters. - * This may disallow certain characters that can appear - * in the prefix used to make the full container name. - * - * @param string $container - * @return bool - */ - final protected static function isValidShortContainerName( $container ) { - // Suffixes like '.xxx' (hex shard chars) or '.seg' (file segments) - // might be used by subclasses. Reserve the dot character for sanity. - // The only way dots end up in containers (e.g. resolveStoragePath) - // is due to the wikiId container prefix or the above suffixes. - return self::isValidContainerName( $container ) && !preg_match( '/[.]/', $container ); - } - - /** - * Check if a full container name is valid - * - * This checks for length and illegal characters. - * Limiting the characters makes migrations to other stores easier. - * - * @param string $container - * @return bool - */ - final protected static function isValidContainerName( $container ) { - // This accounts for NTFS, Swift, and Ceph restrictions - // and disallows directory separators or traversal characters. - // Note that matching strings URL encode to the same string; - // in Swift/Ceph, the length restriction is *after* URL encoding. - return (bool)preg_match( '/^[a-z0-9][a-z0-9-_.]{0,199}$/i', $container ); - } - - /** - * Splits a storage path into an internal container name, - * an internal relative file name, and a container shard suffix. - * Any shard suffix is already appended to the internal container name. - * This also checks that the storage path is valid and within this backend. - * - * If the container is sharded but a suffix could not be determined, - * this means that the path can only refer to a directory and can only - * be scanned by looking in all the container shards. - * - * @param string $storagePath - * @return array (container, path, container suffix) or (null, null, null) if invalid - */ - final protected function resolveStoragePath( $storagePath ) { - list( $backend, $shortCont, $relPath ) = self::splitStoragePath( $storagePath ); - if ( $backend === $this->name ) { // must be for this backend - $relPath = self::normalizeContainerPath( $relPath ); - if ( $relPath !== null && self::isValidShortContainerName( $shortCont ) ) { - // Get shard for the normalized path if this container is sharded - $cShard = $this->getContainerShard( $shortCont, $relPath ); - // Validate and sanitize the relative path (backend-specific) - $relPath = $this->resolveContainerPath( $shortCont, $relPath ); - if ( $relPath !== null ) { - // Prepend any wiki ID prefix to the container name - $container = $this->fullContainerName( $shortCont ); - if ( self::isValidContainerName( $container ) ) { - // Validate and sanitize the container name (backend-specific) - $container = $this->resolveContainerName( "{$container}{$cShard}" ); - if ( $container !== null ) { - return [ $container, $relPath, $cShard ]; - } - } - } - } - } - - return [ null, null, null ]; - } - - /** - * Like resolveStoragePath() except null values are returned if - * the container is sharded and the shard could not be determined - * or if the path ends with '/'. The later case is illegal for FS - * backends and can confuse listings for object store backends. - * - * This function is used when resolving paths that must be valid - * locations for files. Directory and listing functions should - * generally just use resolveStoragePath() instead. - * - * @see FileBackendStore::resolveStoragePath() - * - * @param string $storagePath - * @return array (container, path) or (null, null) if invalid - */ - final protected function resolveStoragePathReal( $storagePath ) { - list( $container, $relPath, $cShard ) = $this->resolveStoragePath( $storagePath ); - if ( $cShard !== null && substr( $relPath, -1 ) !== '/' ) { - return [ $container, $relPath ]; - } - - return [ null, null ]; - } - - /** - * Get the container name shard suffix for a given path. - * Any empty suffix means the container is not sharded. - * - * @param string $container Container name - * @param string $relPath Storage path relative to the container - * @return string|null Returns null if shard could not be determined - */ - final protected function getContainerShard( $container, $relPath ) { - list( $levels, $base, $repeat ) = $this->getContainerHashLevels( $container ); - if ( $levels == 1 || $levels == 2 ) { - // Hash characters are either base 16 or 36 - $char = ( $base == 36 ) ? '[0-9a-z]' : '[0-9a-f]'; - // Get a regex that represents the shard portion of paths. - // The concatenation of the captures gives us the shard. - if ( $levels === 1 ) { // 16 or 36 shards per container - $hashDirRegex = '(' . $char . ')'; - } else { // 256 or 1296 shards per container - if ( $repeat ) { // verbose hash dir format (e.g. "a/ab/abc") - $hashDirRegex = $char . '/(' . $char . '{2})'; - } else { // short hash dir format (e.g. "a/b/c") - $hashDirRegex = '(' . $char . ')/(' . $char . ')'; - } - } - // Allow certain directories to be above the hash dirs so as - // to work with FileRepo (e.g. "archive/a/ab" or "temp/a/ab"). - // They must be 2+ chars to avoid any hash directory ambiguity. - $m = []; - if ( preg_match( "!^(?:[^/]{2,}/)*$hashDirRegex(?:/|$)!", $relPath, $m ) ) { - return '.' . implode( '', array_slice( $m, 1 ) ); - } - - return null; // failed to match - } - - return ''; // no sharding - } - - /** - * Check if a storage path maps to a single shard. - * Container dirs like "a", where the container shards on "x/xy", - * can reside on several shards. Such paths are tricky to handle. - * - * @param string $storagePath Storage path - * @return bool - */ - final public function isSingleShardPathInternal( $storagePath ) { - list( , , $shard ) = $this->resolveStoragePath( $storagePath ); - - return ( $shard !== null ); - } - - /** - * Get the sharding config for a container. - * If greater than 0, then all file storage paths within - * the container are required to be hashed accordingly. - * - * @param string $container - * @return array (integer levels, integer base, repeat flag) or (0, 0, false) - */ - final protected function getContainerHashLevels( $container ) { - if ( isset( $this->shardViaHashLevels[$container] ) ) { - $config = $this->shardViaHashLevels[$container]; - $hashLevels = (int)$config['levels']; - if ( $hashLevels == 1 || $hashLevels == 2 ) { - $hashBase = (int)$config['base']; - if ( $hashBase == 16 || $hashBase == 36 ) { - return [ $hashLevels, $hashBase, $config['repeat'] ]; - } - } - } - - return [ 0, 0, false ]; // no sharding - } - - /** - * Get a list of full container shard suffixes for a container - * - * @param string $container - * @return array - */ - final protected function getContainerSuffixes( $container ) { - $shards = []; - list( $digits, $base ) = $this->getContainerHashLevels( $container ); - if ( $digits > 0 ) { - $numShards = pow( $base, $digits ); - for ( $index = 0; $index < $numShards; $index++ ) { - $shards[] = '.' . Wikimedia\base_convert( $index, 10, $base, $digits ); - } - } - - return $shards; - } - - /** - * Get the full container name, including the wiki ID prefix - * - * @param string $container - * @return string - */ - final protected function fullContainerName( $container ) { - if ( $this->wikiId != '' ) { - return "{$this->wikiId}-$container"; - } else { - return $container; - } - } - - /** - * Resolve a container name, checking if it's allowed by the backend. - * This is intended for internal use, such as encoding illegal chars. - * Subclasses can override this to be more restrictive. - * - * @param string $container - * @return string|null - */ - protected function resolveContainerName( $container ) { - return $container; - } - - /** - * Resolve a relative storage path, checking if it's allowed by the backend. - * This is intended for internal use, such as encoding illegal chars or perhaps - * getting absolute paths (e.g. FS based backends). Note that the relative path - * may be the empty string (e.g. the path is simply to the container). - * - * @param string $container Container name - * @param string $relStoragePath Storage path relative to the container - * @return string|null Path or null if not valid - */ - protected function resolveContainerPath( $container, $relStoragePath ) { - return $relStoragePath; - } - - /** - * Get the cache key for a container - * - * @param string $container Resolved container name - * @return string - */ - private function containerCacheKey( $container ) { - return "filebackend:{$this->name}:{$this->wikiId}:container:{$container}"; - } - - /** - * Set the cached info for a container - * - * @param string $container Resolved container name - * @param array $val Information to cache - */ - final protected function setContainerCache( $container, array $val ) { - $this->memCache->set( $this->containerCacheKey( $container ), $val, 14 * 86400 ); - } - - /** - * Delete the cached info for a container. - * The cache key is salted for a while to prevent race conditions. - * - * @param string $container Resolved container name - */ - final protected function deleteContainerCache( $container ) { - if ( !$this->memCache->delete( $this->containerCacheKey( $container ), 300 ) ) { - trigger_error( "Unable to delete stat cache for container $container." ); - } - } - - /** - * Do a batch lookup from cache for container stats for all containers - * used in a list of container names or storage paths objects. - * This loads the persistent cache values into the process cache. - * - * @param array $items - */ - final protected function primeContainerCache( array $items ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - - $paths = []; // list of storage paths - $contNames = []; // (cache key => resolved container name) - // Get all the paths/containers from the items... - foreach ( $items as $item ) { - if ( self::isStoragePath( $item ) ) { - $paths[] = $item; - } elseif ( is_string( $item ) ) { // full container name - $contNames[$this->containerCacheKey( $item )] = $item; - } - } - // Get all the corresponding cache keys for paths... - foreach ( $paths as $path ) { - list( $fullCont, , ) = $this->resolveStoragePath( $path ); - if ( $fullCont !== null ) { // valid path for this backend - $contNames[$this->containerCacheKey( $fullCont )] = $fullCont; - } - } - - $contInfo = []; // (resolved container name => cache value) - // Get all cache entries for these container cache keys... - $values = $this->memCache->getMulti( array_keys( $contNames ) ); - foreach ( $values as $cacheKey => $val ) { - $contInfo[$contNames[$cacheKey]] = $val; - } - - // Populate the container process cache for the backend... - $this->doPrimeContainerCache( array_filter( $contInfo, 'is_array' ) ); - } - - /** - * Fill the backend-specific process cache given an array of - * resolved container names and their corresponding cached info. - * Only containers that actually exist should appear in the map. - * - * @param array $containerInfo Map of resolved container names to cached info - */ - protected function doPrimeContainerCache( array $containerInfo ) { - } - - /** - * Get the cache key for a file path - * - * @param string $path Normalized storage path - * @return string - */ - private function fileCacheKey( $path ) { - return "filebackend:{$this->name}:{$this->wikiId}:file:" . sha1( $path ); - } - - /** - * Set the cached stat info for a file path. - * Negatives (404s) are not cached. By not caching negatives, we can skip cache - * salting for the case when a file is created at a path were there was none before. - * - * @param string $path Storage path - * @param array $val Stat information to cache - */ - final protected function setFileCache( $path, array $val ) { - $path = FileBackend::normalizeStoragePath( $path ); - if ( $path === null ) { - return; // invalid storage path - } - $age = time() - wfTimestamp( TS_UNIX, $val['mtime'] ); - $ttl = min( 7 * 86400, max( 300, floor( .1 * $age ) ) ); - $key = $this->fileCacheKey( $path ); - // Set the cache unless it is currently salted. - $this->memCache->set( $key, $val, $ttl ); - } - - /** - * Delete the cached stat info for a file path. - * The cache key is salted for a while to prevent race conditions. - * Since negatives (404s) are not cached, this does not need to be called when - * a file is created at a path were there was none before. - * - * @param string $path Storage path - */ - final protected function deleteFileCache( $path ) { - $path = FileBackend::normalizeStoragePath( $path ); - if ( $path === null ) { - return; // invalid storage path - } - if ( !$this->memCache->delete( $this->fileCacheKey( $path ), 300 ) ) { - trigger_error( "Unable to delete stat cache for file $path." ); - } - } - - /** - * Do a batch lookup from cache for file stats for all paths - * used in a list of storage paths or FileOp objects. - * This loads the persistent cache values into the process cache. - * - * @param array $items List of storage paths - */ - final protected function primeFileCache( array $items ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - - $paths = []; // list of storage paths - $pathNames = []; // (cache key => storage path) - // Get all the paths/containers from the items... - foreach ( $items as $item ) { - if ( self::isStoragePath( $item ) ) { - $paths[] = FileBackend::normalizeStoragePath( $item ); - } - } - // Get rid of any paths that failed normalization... - $paths = array_filter( $paths, 'strlen' ); // remove nulls - // Get all the corresponding cache keys for paths... - foreach ( $paths as $path ) { - list( , $rel, ) = $this->resolveStoragePath( $path ); - if ( $rel !== null ) { // valid path for this backend - $pathNames[$this->fileCacheKey( $path )] = $path; - } - } - // Get all cache entries for these file cache keys... - $values = $this->memCache->getMulti( array_keys( $pathNames ) ); - foreach ( $values as $cacheKey => $val ) { - $path = $pathNames[$cacheKey]; - if ( is_array( $val ) ) { - $val['latest'] = false; // never completely trust cache - $this->cheapCache->set( $path, 'stat', $val ); - if ( isset( $val['sha1'] ) ) { // some backends store SHA-1 as metadata - $this->cheapCache->set( $path, 'sha1', - [ 'hash' => $val['sha1'], 'latest' => false ] ); - } - if ( isset( $val['xattr'] ) ) { // some backends store headers/metadata - $val['xattr'] = self::normalizeXAttributes( $val['xattr'] ); - $this->cheapCache->set( $path, 'xattr', - [ 'map' => $val['xattr'], 'latest' => false ] ); - } - } - } - } - - /** - * Normalize file headers/metadata to the FileBackend::getFileXAttributes() format - * - * @param array $xattr - * @return array - * @since 1.22 - */ - final protected static function normalizeXAttributes( array $xattr ) { - $newXAttr = [ 'headers' => [], 'metadata' => [] ]; - - foreach ( $xattr['headers'] as $name => $value ) { - $newXAttr['headers'][strtolower( $name )] = $value; - } - - foreach ( $xattr['metadata'] as $name => $value ) { - $newXAttr['metadata'][strtolower( $name )] = $value; - } - - return $newXAttr; - } - - /** - * Set the 'concurrency' option from a list of operation options - * - * @param array $opts Map of operation options - * @return array - */ - final protected function setConcurrencyFlags( array $opts ) { - $opts['concurrency'] = 1; // off - if ( $this->parallelize === 'implicit' ) { - if ( !isset( $opts['parallelize'] ) || $opts['parallelize'] ) { - $opts['concurrency'] = $this->concurrency; - } - } elseif ( $this->parallelize === 'explicit' ) { - if ( !empty( $opts['parallelize'] ) ) { - $opts['concurrency'] = $this->concurrency; - } - } - - return $opts; - } - - /** - * Get the content type to use in HEAD/GET requests for a file - * - * @param string $storagePath - * @param string|null $content File data - * @param string|null $fsPath File system path - * @return string MIME type - */ - protected function getContentType( $storagePath, $content, $fsPath ) { - if ( $this->mimeCallback ) { - return call_user_func_array( $this->mimeCallback, func_get_args() ); - } - - $mime = null; - if ( $fsPath !== null && function_exists( 'finfo_file' ) ) { - $finfo = finfo_open( FILEINFO_MIME_TYPE ); - $mime = finfo_file( $finfo, $fsPath ); - finfo_close( $finfo ); - } - - return is_string( $mime ) ? $mime : 'unknown/unknown'; - } -} - -/** - * FileBackendStore helper class for performing asynchronous file operations. - * - * For example, calling FileBackendStore::createInternal() with the "async" - * param flag may result in a Status that contains this object as a value. - * This class is largely backend-specific and is mostly just "magic" to be - * passed to FileBackendStore::executeOpHandlesInternal(). - */ -abstract class FileBackendStoreOpHandle { - /** @var array */ - public $params = []; // params to caller functions - /** @var FileBackendStore */ - public $backend; - /** @var array */ - public $resourcesToClose = []; - - public $call; // string; name that identifies the function called - - /** - * Close all open file handles - */ - public function closeResources() { - array_map( 'fclose', $this->resourcesToClose ); - } -} - -/** - * FileBackendStore helper function to handle listings that span container shards. - * Do not use this class from places outside of FileBackendStore. - * - * @ingroup FileBackend - */ -abstract class FileBackendStoreShardListIterator extends FilterIterator { - /** @var FileBackendStore */ - protected $backend; - - /** @var array */ - protected $params; - - /** @var string Full container name */ - protected $container; - - /** @var string Resolved relative path */ - protected $directory; - - /** @var array */ - protected $multiShardPaths = []; // (rel path => 1) - - /** - * @param FileBackendStore $backend - * @param string $container Full storage container name - * @param string $dir Storage directory relative to container - * @param array $suffixes List of container shard suffixes - * @param array $params - */ - public function __construct( - FileBackendStore $backend, $container, $dir, array $suffixes, array $params - ) { - $this->backend = $backend; - $this->container = $container; - $this->directory = $dir; - $this->params = $params; - - $iter = new AppendIterator(); - foreach ( $suffixes as $suffix ) { - $iter->append( $this->listFromShard( $this->container . $suffix ) ); - } - - parent::__construct( $iter ); - } - - public function accept() { - $rel = $this->getInnerIterator()->current(); // path relative to given directory - $path = $this->params['dir'] . "/{$rel}"; // full storage path - if ( $this->backend->isSingleShardPathInternal( $path ) ) { - return true; // path is only on one shard; no issue with duplicates - } elseif ( isset( $this->multiShardPaths[$rel] ) ) { - // Don't keep listing paths that are on multiple shards - return false; - } else { - $this->multiShardPaths[$rel] = 1; - - return true; - } - } - - public function rewind() { - parent::rewind(); - $this->multiShardPaths = []; - } - - /** - * Get the list for a given container shard - * - * @param string $container Resolved container name - * @return Iterator - */ - abstract protected function listFromShard( $container ); -} - -/** - * Iterator for listing directories - */ -class FileBackendStoreShardDirIterator extends FileBackendStoreShardListIterator { - protected function listFromShard( $container ) { - $list = $this->backend->getDirectoryListInternal( - $container, $this->directory, $this->params ); - if ( $list === null ) { - return new ArrayIterator( [] ); - } else { - return is_array( $list ) ? new ArrayIterator( $list ) : $list; - } - } -} - -/** - * Iterator for listing regular files - */ -class FileBackendStoreShardFileIterator extends FileBackendStoreShardListIterator { - protected function listFromShard( $container ) { - $list = $this->backend->getFileListInternal( - $container, $this->directory, $this->params ); - if ( $list === null ) { - return new ArrayIterator( [] ); - } else { - return is_array( $list ) ? new ArrayIterator( $list ) : $list; - } - } -} diff --git a/includes/filebackend/FileOp.php b/includes/filebackend/FileOp.php deleted file mode 100644 index 56a40738e6..0000000000 --- a/includes/filebackend/FileOp.php +++ /dev/null @@ -1,848 +0,0 @@ -backend = $backend; - list( $required, $optional, $paths ) = $this->allowedParams(); - foreach ( $required as $name ) { - if ( isset( $params[$name] ) ) { - $this->params[$name] = $params[$name]; - } else { - throw new FileBackendError( "File operation missing parameter '$name'." ); - } - } - foreach ( $optional as $name ) { - if ( isset( $params[$name] ) ) { - $this->params[$name] = $params[$name]; - } - } - foreach ( $paths as $name ) { - if ( isset( $this->params[$name] ) ) { - // Normalize paths so the paths to the same file have the same string - $this->params[$name] = self::normalizeIfValidStoragePath( $this->params[$name] ); - } - } - } - - /** - * Normalize a string if it is a valid storage path - * - * @param string $path - * @return string - */ - protected static function normalizeIfValidStoragePath( $path ) { - if ( FileBackend::isStoragePath( $path ) ) { - $res = FileBackend::normalizeStoragePath( $path ); - - return ( $res !== null ) ? $res : $path; - } - - return $path; - } - - /** - * Set the batch UUID this operation belongs to - * - * @param string $batchId - */ - final public function setBatchId( $batchId ) { - $this->batchId = $batchId; - } - - /** - * Get the value of the parameter with the given name - * - * @param string $name - * @return mixed Returns null if the parameter is not set - */ - final public function getParam( $name ) { - return isset( $this->params[$name] ) ? $this->params[$name] : null; - } - - /** - * Check if this operation failed precheck() or attempt() - * - * @return bool - */ - final public function failed() { - return $this->failed; - } - - /** - * Get a new empty predicates array for precheck() - * - * @return array - */ - final public static function newPredicates() { - return [ 'exists' => [], 'sha1' => [] ]; - } - - /** - * Get a new empty dependency tracking array for paths read/written to - * - * @return array - */ - final public static function newDependencies() { - return [ 'read' => [], 'write' => [] ]; - } - - /** - * Update a dependency tracking array to account for this operation - * - * @param array $deps Prior path reads/writes; format of FileOp::newPredicates() - * @return array - */ - final public function applyDependencies( array $deps ) { - $deps['read'] += array_fill_keys( $this->storagePathsRead(), 1 ); - $deps['write'] += array_fill_keys( $this->storagePathsChanged(), 1 ); - - return $deps; - } - - /** - * Check if this operation changes files listed in $paths - * - * @param array $deps Prior path reads/writes; format of FileOp::newPredicates() - * @return bool - */ - final public function dependsOn( array $deps ) { - foreach ( $this->storagePathsChanged() as $path ) { - if ( isset( $deps['read'][$path] ) || isset( $deps['write'][$path] ) ) { - return true; // "output" or "anti" dependency - } - } - foreach ( $this->storagePathsRead() as $path ) { - if ( isset( $deps['write'][$path] ) ) { - return true; // "flow" dependency - } - } - - return false; - } - - /** - * Get the file journal entries for this file operation - * - * @param array $oPredicates Pre-op info about files (format of FileOp::newPredicates) - * @param array $nPredicates Post-op info about files (format of FileOp::newPredicates) - * @return array - */ - final public function getJournalEntries( array $oPredicates, array $nPredicates ) { - if ( !$this->doOperation ) { - return []; // this is a no-op - } - $nullEntries = []; - $updateEntries = []; - $deleteEntries = []; - $pathsUsed = array_merge( $this->storagePathsRead(), $this->storagePathsChanged() ); - foreach ( array_unique( $pathsUsed ) as $path ) { - $nullEntries[] = [ // assertion for recovery - 'op' => 'null', - 'path' => $path, - 'newSha1' => $this->fileSha1( $path, $oPredicates ) - ]; - } - foreach ( $this->storagePathsChanged() as $path ) { - if ( $nPredicates['sha1'][$path] === false ) { // deleted - $deleteEntries[] = [ - 'op' => 'delete', - 'path' => $path, - 'newSha1' => '' - ]; - } else { // created/updated - $updateEntries[] = [ - 'op' => $this->fileExists( $path, $oPredicates ) ? 'update' : 'create', - 'path' => $path, - 'newSha1' => $nPredicates['sha1'][$path] - ]; - } - } - - return array_merge( $nullEntries, $updateEntries, $deleteEntries ); - } - - /** - * Check preconditions of the operation without writing anything. - * This must update $predicates for each path that the op can change - * except when a failing status object is returned. - * - * @param array $predicates - * @return Status - */ - final public function precheck( array &$predicates ) { - if ( $this->state !== self::STATE_NEW ) { - return Status::newFatal( 'fileop-fail-state', self::STATE_NEW, $this->state ); - } - $this->state = self::STATE_CHECKED; - $status = $this->doPrecheck( $predicates ); - if ( !$status->isOK() ) { - $this->failed = true; - } - - return $status; - } - - /** - * @param array $predicates - * @return Status - */ - protected function doPrecheck( array &$predicates ) { - return Status::newGood(); - } - - /** - * Attempt the operation - * - * @return Status - */ - final public function attempt() { - if ( $this->state !== self::STATE_CHECKED ) { - return Status::newFatal( 'fileop-fail-state', self::STATE_CHECKED, $this->state ); - } elseif ( $this->failed ) { // failed precheck - return Status::newFatal( 'fileop-fail-attempt-precheck' ); - } - $this->state = self::STATE_ATTEMPTED; - if ( $this->doOperation ) { - $status = $this->doAttempt(); - if ( !$status->isOK() ) { - $this->failed = true; - $this->logFailure( 'attempt' ); - } - } else { // no-op - $status = Status::newGood(); - } - - return $status; - } - - /** - * @return Status - */ - protected function doAttempt() { - return Status::newGood(); - } - - /** - * Attempt the operation in the background - * - * @return Status - */ - final public function attemptAsync() { - $this->async = true; - $result = $this->attempt(); - $this->async = false; - - return $result; - } - - /** - * Get the file operation parameters - * - * @return array (required params list, optional params list, list of params that are paths) - */ - protected function allowedParams() { - return [ [], [], [] ]; - } - - /** - * Adjust params to FileBackendStore internal file calls - * - * @param array $params - * @return array (required params list, optional params list) - */ - protected function setFlags( array $params ) { - return [ 'async' => $this->async ] + $params; - } - - /** - * Get a list of storage paths read from for this operation - * - * @return array - */ - public function storagePathsRead() { - return []; - } - - /** - * Get a list of storage paths written to for this operation - * - * @return array - */ - public function storagePathsChanged() { - return []; - } - - /** - * Check for errors with regards to the destination file already existing. - * Also set the destExists, overwriteSameCase and sourceSha1 member variables. - * A bad status will be returned if there is no chance it can be overwritten. - * - * @param array $predicates - * @return Status - */ - protected function precheckDestExistence( array $predicates ) { - $status = Status::newGood(); - // Get hash of source file/string and the destination file - $this->sourceSha1 = $this->getSourceSha1Base36(); // FS file or data string - if ( $this->sourceSha1 === null ) { // file in storage? - $this->sourceSha1 = $this->fileSha1( $this->params['src'], $predicates ); - } - $this->overwriteSameCase = false; - $this->destExists = $this->fileExists( $this->params['dst'], $predicates ); - if ( $this->destExists ) { - if ( $this->getParam( 'overwrite' ) ) { - return $status; // OK - } elseif ( $this->getParam( 'overwriteSame' ) ) { - $dhash = $this->fileSha1( $this->params['dst'], $predicates ); - // Check if hashes are valid and match each other... - if ( !strlen( $this->sourceSha1 ) || !strlen( $dhash ) ) { - $status->fatal( 'backend-fail-hashes' ); - } elseif ( $this->sourceSha1 !== $dhash ) { - // Give an error if the files are not identical - $status->fatal( 'backend-fail-notsame', $this->params['dst'] ); - } else { - $this->overwriteSameCase = true; // OK - } - - return $status; // do nothing; either OK or bad status - } else { - $status->fatal( 'backend-fail-alreadyexists', $this->params['dst'] ); - - return $status; - } - } - - return $status; - } - - /** - * precheckDestExistence() helper function to get the source file SHA-1. - * Subclasses should overwride this if the source is not in storage. - * - * @return string|bool Returns false on failure - */ - protected function getSourceSha1Base36() { - return null; // N/A - } - - /** - * Check if a file will exist in storage when this operation is attempted - * - * @param string $source Storage path - * @param array $predicates - * @return bool - */ - final protected function fileExists( $source, array $predicates ) { - if ( isset( $predicates['exists'][$source] ) ) { - return $predicates['exists'][$source]; // previous op assures this - } else { - $params = [ 'src' => $source, 'latest' => true ]; - - return $this->backend->fileExists( $params ); - } - } - - /** - * Get the SHA-1 of a file in storage when this operation is attempted - * - * @param string $source Storage path - * @param array $predicates - * @return string|bool False on failure - */ - final protected function fileSha1( $source, array $predicates ) { - if ( isset( $predicates['sha1'][$source] ) ) { - return $predicates['sha1'][$source]; // previous op assures this - } elseif ( isset( $predicates['exists'][$source] ) && !$predicates['exists'][$source] ) { - return false; // previous op assures this - } else { - $params = [ 'src' => $source, 'latest' => true ]; - - return $this->backend->getFileSha1Base36( $params ); - } - } - - /** - * Get the backend this operation is for - * - * @return FileBackendStore - */ - public function getBackend() { - return $this->backend; - } - - /** - * Log a file operation failure and preserve any temp files - * - * @param string $action - */ - final public function logFailure( $action ) { - $params = $this->params; - $params['failedAction'] = $action; - try { - wfDebugLog( 'FileOperation', get_class( $this ) . - " failed (batch #{$this->batchId}): " . FormatJson::encode( $params ) ); - } catch ( Exception $e ) { - // bad config? debug log error? - } - } -} - -/** - * Create a file in the backend with the given content. - * Parameters for this operation are outlined in FileBackend::doOperations(). - */ -class CreateFileOp extends FileOp { - protected function allowedParams() { - return [ - [ 'content', 'dst' ], - [ 'overwrite', 'overwriteSame', 'headers' ], - [ 'dst' ] - ]; - } - - protected function doPrecheck( array &$predicates ) { - $status = Status::newGood(); - // Check if the source data is too big - if ( strlen( $this->getParam( 'content' ) ) > $this->backend->maxFileSizeInternal() ) { - $status->fatal( 'backend-fail-maxsize', - $this->params['dst'], $this->backend->maxFileSizeInternal() ); - $status->fatal( 'backend-fail-create', $this->params['dst'] ); - - return $status; - // Check if a file can be placed/changed at the destination - } elseif ( !$this->backend->isPathUsableInternal( $this->params['dst'] ) ) { - $status->fatal( 'backend-fail-usable', $this->params['dst'] ); - $status->fatal( 'backend-fail-create', $this->params['dst'] ); - - return $status; - } - // Check if destination file exists - $status->merge( $this->precheckDestExistence( $predicates ) ); - $this->params['dstExists'] = $this->destExists; // see FileBackendStore::setFileCache() - if ( $status->isOK() ) { - // Update file existence predicates - $predicates['exists'][$this->params['dst']] = true; - $predicates['sha1'][$this->params['dst']] = $this->sourceSha1; - } - - return $status; // safe to call attempt() - } - - protected function doAttempt() { - if ( !$this->overwriteSameCase ) { - // Create the file at the destination - return $this->backend->createInternal( $this->setFlags( $this->params ) ); - } - - return Status::newGood(); - } - - protected function getSourceSha1Base36() { - return Wikimedia\base_convert( sha1( $this->params['content'] ), 16, 36, 31 ); - } - - public function storagePathsChanged() { - return [ $this->params['dst'] ]; - } -} - -/** - * Store a file into the backend from a file on the file system. - * Parameters for this operation are outlined in FileBackend::doOperations(). - */ -class StoreFileOp extends FileOp { - protected function allowedParams() { - return [ - [ 'src', 'dst' ], - [ 'overwrite', 'overwriteSame', 'headers' ], - [ 'src', 'dst' ] - ]; - } - - protected function doPrecheck( array &$predicates ) { - $status = Status::newGood(); - // Check if the source file exists on the file system - if ( !is_file( $this->params['src'] ) ) { - $status->fatal( 'backend-fail-notexists', $this->params['src'] ); - - return $status; - // Check if the source file is too big - } elseif ( filesize( $this->params['src'] ) > $this->backend->maxFileSizeInternal() ) { - $status->fatal( 'backend-fail-maxsize', - $this->params['dst'], $this->backend->maxFileSizeInternal() ); - $status->fatal( 'backend-fail-store', $this->params['src'], $this->params['dst'] ); - - return $status; - // Check if a file can be placed/changed at the destination - } elseif ( !$this->backend->isPathUsableInternal( $this->params['dst'] ) ) { - $status->fatal( 'backend-fail-usable', $this->params['dst'] ); - $status->fatal( 'backend-fail-store', $this->params['src'], $this->params['dst'] ); - - return $status; - } - // Check if destination file exists - $status->merge( $this->precheckDestExistence( $predicates ) ); - $this->params['dstExists'] = $this->destExists; // see FileBackendStore::setFileCache() - if ( $status->isOK() ) { - // Update file existence predicates - $predicates['exists'][$this->params['dst']] = true; - $predicates['sha1'][$this->params['dst']] = $this->sourceSha1; - } - - return $status; // safe to call attempt() - } - - protected function doAttempt() { - if ( !$this->overwriteSameCase ) { - // Store the file at the destination - return $this->backend->storeInternal( $this->setFlags( $this->params ) ); - } - - return Status::newGood(); - } - - protected function getSourceSha1Base36() { - MediaWiki\suppressWarnings(); - $hash = sha1_file( $this->params['src'] ); - MediaWiki\restoreWarnings(); - if ( $hash !== false ) { - $hash = Wikimedia\base_convert( $hash, 16, 36, 31 ); - } - - return $hash; - } - - public function storagePathsChanged() { - return [ $this->params['dst'] ]; - } -} - -/** - * Copy a file from one storage path to another in the backend. - * Parameters for this operation are outlined in FileBackend::doOperations(). - */ -class CopyFileOp extends FileOp { - protected function allowedParams() { - return [ - [ 'src', 'dst' ], - [ 'overwrite', 'overwriteSame', 'ignoreMissingSource', 'headers' ], - [ 'src', 'dst' ] - ]; - } - - protected function doPrecheck( array &$predicates ) { - $status = Status::newGood(); - // Check if the source file exists - if ( !$this->fileExists( $this->params['src'], $predicates ) ) { - if ( $this->getParam( 'ignoreMissingSource' ) ) { - $this->doOperation = false; // no-op - // Update file existence predicates (cache 404s) - $predicates['exists'][$this->params['src']] = false; - $predicates['sha1'][$this->params['src']] = false; - - return $status; // nothing to do - } else { - $status->fatal( 'backend-fail-notexists', $this->params['src'] ); - - return $status; - } - // Check if a file can be placed/changed at the destination - } elseif ( !$this->backend->isPathUsableInternal( $this->params['dst'] ) ) { - $status->fatal( 'backend-fail-usable', $this->params['dst'] ); - $status->fatal( 'backend-fail-copy', $this->params['src'], $this->params['dst'] ); - - return $status; - } - // Check if destination file exists - $status->merge( $this->precheckDestExistence( $predicates ) ); - $this->params['dstExists'] = $this->destExists; // see FileBackendStore::setFileCache() - if ( $status->isOK() ) { - // Update file existence predicates - $predicates['exists'][$this->params['dst']] = true; - $predicates['sha1'][$this->params['dst']] = $this->sourceSha1; - } - - return $status; // safe to call attempt() - } - - protected function doAttempt() { - if ( $this->overwriteSameCase ) { - $status = Status::newGood(); // nothing to do - } elseif ( $this->params['src'] === $this->params['dst'] ) { - // Just update the destination file headers - $headers = $this->getParam( 'headers' ) ?: []; - $status = $this->backend->describeInternal( $this->setFlags( [ - 'src' => $this->params['dst'], 'headers' => $headers - ] ) ); - } else { - // Copy the file to the destination - $status = $this->backend->copyInternal( $this->setFlags( $this->params ) ); - } - - return $status; - } - - public function storagePathsRead() { - return [ $this->params['src'] ]; - } - - public function storagePathsChanged() { - return [ $this->params['dst'] ]; - } -} - -/** - * Move a file from one storage path to another in the backend. - * Parameters for this operation are outlined in FileBackend::doOperations(). - */ -class MoveFileOp extends FileOp { - protected function allowedParams() { - return [ - [ 'src', 'dst' ], - [ 'overwrite', 'overwriteSame', 'ignoreMissingSource', 'headers' ], - [ 'src', 'dst' ] - ]; - } - - protected function doPrecheck( array &$predicates ) { - $status = Status::newGood(); - // Check if the source file exists - if ( !$this->fileExists( $this->params['src'], $predicates ) ) { - if ( $this->getParam( 'ignoreMissingSource' ) ) { - $this->doOperation = false; // no-op - // Update file existence predicates (cache 404s) - $predicates['exists'][$this->params['src']] = false; - $predicates['sha1'][$this->params['src']] = false; - - return $status; // nothing to do - } else { - $status->fatal( 'backend-fail-notexists', $this->params['src'] ); - - return $status; - } - // Check if a file can be placed/changed at the destination - } elseif ( !$this->backend->isPathUsableInternal( $this->params['dst'] ) ) { - $status->fatal( 'backend-fail-usable', $this->params['dst'] ); - $status->fatal( 'backend-fail-move', $this->params['src'], $this->params['dst'] ); - - return $status; - } - // Check if destination file exists - $status->merge( $this->precheckDestExistence( $predicates ) ); - $this->params['dstExists'] = $this->destExists; // see FileBackendStore::setFileCache() - if ( $status->isOK() ) { - // Update file existence predicates - $predicates['exists'][$this->params['src']] = false; - $predicates['sha1'][$this->params['src']] = false; - $predicates['exists'][$this->params['dst']] = true; - $predicates['sha1'][$this->params['dst']] = $this->sourceSha1; - } - - return $status; // safe to call attempt() - } - - protected function doAttempt() { - if ( $this->overwriteSameCase ) { - if ( $this->params['src'] === $this->params['dst'] ) { - // Do nothing to the destination (which is also the source) - $status = Status::newGood(); - } else { - // Just delete the source as the destination file needs no changes - $status = $this->backend->deleteInternal( $this->setFlags( - [ 'src' => $this->params['src'] ] - ) ); - } - } elseif ( $this->params['src'] === $this->params['dst'] ) { - // Just update the destination file headers - $headers = $this->getParam( 'headers' ) ?: []; - $status = $this->backend->describeInternal( $this->setFlags( - [ 'src' => $this->params['dst'], 'headers' => $headers ] - ) ); - } else { - // Move the file to the destination - $status = $this->backend->moveInternal( $this->setFlags( $this->params ) ); - } - - return $status; - } - - public function storagePathsRead() { - return [ $this->params['src'] ]; - } - - public function storagePathsChanged() { - return [ $this->params['src'], $this->params['dst'] ]; - } -} - -/** - * Delete a file at the given storage path from the backend. - * Parameters for this operation are outlined in FileBackend::doOperations(). - */ -class DeleteFileOp extends FileOp { - protected function allowedParams() { - return [ [ 'src' ], [ 'ignoreMissingSource' ], [ 'src' ] ]; - } - - protected function doPrecheck( array &$predicates ) { - $status = Status::newGood(); - // Check if the source file exists - if ( !$this->fileExists( $this->params['src'], $predicates ) ) { - if ( $this->getParam( 'ignoreMissingSource' ) ) { - $this->doOperation = false; // no-op - // Update file existence predicates (cache 404s) - $predicates['exists'][$this->params['src']] = false; - $predicates['sha1'][$this->params['src']] = false; - - return $status; // nothing to do - } else { - $status->fatal( 'backend-fail-notexists', $this->params['src'] ); - - return $status; - } - // Check if a file can be placed/changed at the source - } elseif ( !$this->backend->isPathUsableInternal( $this->params['src'] ) ) { - $status->fatal( 'backend-fail-usable', $this->params['src'] ); - $status->fatal( 'backend-fail-delete', $this->params['src'] ); - - return $status; - } - // Update file existence predicates - $predicates['exists'][$this->params['src']] = false; - $predicates['sha1'][$this->params['src']] = false; - - return $status; // safe to call attempt() - } - - protected function doAttempt() { - // Delete the source file - return $this->backend->deleteInternal( $this->setFlags( $this->params ) ); - } - - public function storagePathsChanged() { - return [ $this->params['src'] ]; - } -} - -/** - * Change metadata for a file at the given storage path in the backend. - * Parameters for this operation are outlined in FileBackend::doOperations(). - */ -class DescribeFileOp extends FileOp { - protected function allowedParams() { - return [ [ 'src' ], [ 'headers' ], [ 'src' ] ]; - } - - protected function doPrecheck( array &$predicates ) { - $status = Status::newGood(); - // Check if the source file exists - if ( !$this->fileExists( $this->params['src'], $predicates ) ) { - $status->fatal( 'backend-fail-notexists', $this->params['src'] ); - - return $status; - // Check if a file can be placed/changed at the source - } elseif ( !$this->backend->isPathUsableInternal( $this->params['src'] ) ) { - $status->fatal( 'backend-fail-usable', $this->params['src'] ); - $status->fatal( 'backend-fail-describe', $this->params['src'] ); - - return $status; - } - // Update file existence predicates - $predicates['exists'][$this->params['src']] = - $this->fileExists( $this->params['src'], $predicates ); - $predicates['sha1'][$this->params['src']] = - $this->fileSha1( $this->params['src'], $predicates ); - - return $status; // safe to call attempt() - } - - protected function doAttempt() { - // Update the source file's metadata - return $this->backend->describeInternal( $this->setFlags( $this->params ) ); - } - - public function storagePathsChanged() { - return [ $this->params['src'] ]; - } -} - -/** - * Placeholder operation that has no params and does nothing - */ -class NullFileOp extends FileOp { -} diff --git a/includes/filebackend/FileOpBatch.php b/includes/filebackend/FileOpBatch.php deleted file mode 100644 index 78209d8bf8..0000000000 --- a/includes/filebackend/FileOpBatch.php +++ /dev/null @@ -1,202 +0,0 @@ - self::MAX_BATCH_SIZE ) { - $status->fatal( 'backend-fail-batchsize', $n, self::MAX_BATCH_SIZE ); - - return $status; - } - - $batchId = $journal->getTimestampedUUID(); - $ignoreErrors = !empty( $opts['force'] ); - $journaled = empty( $opts['nonJournaled'] ); - $maxConcurrency = isset( $opts['concurrency'] ) ? $opts['concurrency'] : 1; - - $entries = []; // file journal entry list - $predicates = FileOp::newPredicates(); // account for previous ops in prechecks - $curBatch = []; // concurrent FileOp sub-batch accumulation - $curBatchDeps = FileOp::newDependencies(); // paths used in FileOp sub-batch - $pPerformOps = []; // ordered list of concurrent FileOp sub-batches - $lastBackend = null; // last op backend name - // Do pre-checks for each operation; abort on failure... - foreach ( $performOps as $index => $fileOp ) { - $backendName = $fileOp->getBackend()->getName(); - $fileOp->setBatchId( $batchId ); // transaction ID - // Decide if this op can be done concurrently within this sub-batch - // or if a new concurrent sub-batch must be started after this one... - if ( $fileOp->dependsOn( $curBatchDeps ) - || count( $curBatch ) >= $maxConcurrency - || ( $backendName !== $lastBackend && count( $curBatch ) ) - ) { - $pPerformOps[] = $curBatch; // push this batch - $curBatch = []; // start a new sub-batch - $curBatchDeps = FileOp::newDependencies(); - } - $lastBackend = $backendName; - $curBatch[$index] = $fileOp; // keep index - // Update list of affected paths in this batch - $curBatchDeps = $fileOp->applyDependencies( $curBatchDeps ); - // Simulate performing the operation... - $oldPredicates = $predicates; - $subStatus = $fileOp->precheck( $predicates ); // updates $predicates - $status->merge( $subStatus ); - if ( $subStatus->isOK() ) { - if ( $journaled ) { // journal log entries - $entries = array_merge( $entries, - $fileOp->getJournalEntries( $oldPredicates, $predicates ) ); - } - } else { // operation failed? - $status->success[$index] = false; - ++$status->failCount; - if ( !$ignoreErrors ) { - return $status; // abort - } - } - } - // Push the last sub-batch - if ( count( $curBatch ) ) { - $pPerformOps[] = $curBatch; - } - - // Log the operations in the file journal... - if ( count( $entries ) ) { - $subStatus = $journal->logChangeBatch( $entries, $batchId ); - if ( !$subStatus->isOK() ) { - return $subStatus; // abort - } - } - - if ( $ignoreErrors ) { // treat precheck() fatals as mere warnings - $status->setResult( true, $status->value ); - } - - // Attempt each operation (in parallel if allowed and possible)... - self::runParallelBatches( $pPerformOps, $status ); - - return $status; - } - - /** - * Attempt a list of file operations sub-batches in series. - * - * The operations *in* each sub-batch will be done in parallel. - * The caller is responsible for making sure the operations - * within any given sub-batch do not depend on each other. - * This will abort remaining ops on failure. - * - * @param array $pPerformOps Batches of file ops (batches use original indexes) - * @param Status $status - */ - protected static function runParallelBatches( array $pPerformOps, Status $status ) { - $aborted = false; // set to true on unexpected errors - foreach ( $pPerformOps as $performOpsBatch ) { - /** @var FileOp[] $performOpsBatch */ - if ( $aborted ) { // check batch op abort flag... - // We can't continue (even with $ignoreErrors) as $predicates is wrong. - // Log the remaining ops as failed for recovery... - foreach ( $performOpsBatch as $i => $fileOp ) { - $status->success[$i] = false; - ++$status->failCount; - $performOpsBatch[$i]->logFailure( 'attempt_aborted' ); - } - continue; - } - /** @var Status[] $statuses */ - $statuses = []; - $opHandles = []; - // Get the backend; all sub-batch ops belong to a single backend - $backend = reset( $performOpsBatch )->getBackend(); - // Get the operation handles or actually do it if there is just one. - // If attemptAsync() returns a Status, it was either due to an error - // or the backend does not support async ops and did it synchronously. - foreach ( $performOpsBatch as $i => $fileOp ) { - if ( !isset( $status->success[$i] ) ) { // didn't already fail in precheck() - // Parallel ops may be disabled in config due to missing dependencies, - // (e.g. needing popen()). When they are, $performOpsBatch has size 1. - $subStatus = ( count( $performOpsBatch ) > 1 ) - ? $fileOp->attemptAsync() - : $fileOp->attempt(); - if ( $subStatus->value instanceof FileBackendStoreOpHandle ) { - $opHandles[$i] = $subStatus->value; // deferred - } else { - $statuses[$i] = $subStatus; // done already - } - } - } - // Try to do all the operations concurrently... - $statuses = $statuses + $backend->executeOpHandlesInternal( $opHandles ); - // Marshall and merge all the responses (blocking)... - foreach ( $performOpsBatch as $i => $fileOp ) { - if ( !isset( $status->success[$i] ) ) { // didn't already fail in precheck() - $subStatus = $statuses[$i]; - $status->merge( $subStatus ); - if ( $subStatus->isOK() ) { - $status->success[$i] = true; - ++$status->successCount; - } else { - $status->success[$i] = false; - ++$status->failCount; - $aborted = true; // set abort flag; we can't continue - } - } - } - } - } -} diff --git a/includes/filebackend/MemoryFileBackend.php b/includes/filebackend/MemoryFileBackend.php deleted file mode 100644 index 6e32c6292d..0000000000 --- a/includes/filebackend/MemoryFileBackend.php +++ /dev/null @@ -1,278 +0,0 @@ - (data,mtime) */ - protected $files = []; - - public function getFeatures() { - return self::ATTR_UNICODE_PATHS; - } - - public function isPathUsableInternal( $storagePath ) { - return true; - } - - protected function doCreateInternal( array $params ) { - $status = Status::newGood(); - - $dst = $this->resolveHashKey( $params['dst'] ); - if ( $dst === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['dst'] ); - - return $status; - } - - $this->files[$dst] = [ - 'data' => $params['content'], - 'mtime' => wfTimestamp( TS_MW, time() ) - ]; - - return $status; - } - - protected function doStoreInternal( array $params ) { - $status = Status::newGood(); - - $dst = $this->resolveHashKey( $params['dst'] ); - if ( $dst === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['dst'] ); - - return $status; - } - - MediaWiki\suppressWarnings(); - $data = file_get_contents( $params['src'] ); - MediaWiki\restoreWarnings(); - if ( $data === false ) { // source doesn't exist? - $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] ); - - return $status; - } - - $this->files[$dst] = [ - 'data' => $data, - 'mtime' => wfTimestamp( TS_MW, time() ) - ]; - - return $status; - } - - protected function doCopyInternal( array $params ) { - $status = Status::newGood(); - - $src = $this->resolveHashKey( $params['src'] ); - if ( $src === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['src'] ); - - return $status; - } - - $dst = $this->resolveHashKey( $params['dst'] ); - if ( $dst === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['dst'] ); - - return $status; - } - - if ( !isset( $this->files[$src] ) ) { - if ( empty( $params['ignoreMissingSource'] ) ) { - $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] ); - } - - return $status; - } - - $this->files[$dst] = [ - 'data' => $this->files[$src]['data'], - 'mtime' => wfTimestamp( TS_MW, time() ) - ]; - - return $status; - } - - protected function doDeleteInternal( array $params ) { - $status = Status::newGood(); - - $src = $this->resolveHashKey( $params['src'] ); - if ( $src === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['src'] ); - - return $status; - } - - if ( !isset( $this->files[$src] ) ) { - if ( empty( $params['ignoreMissingSource'] ) ) { - $status->fatal( 'backend-fail-delete', $params['src'] ); - } - - return $status; - } - - unset( $this->files[$src] ); - - return $status; - } - - protected function doGetFileStat( array $params ) { - $src = $this->resolveHashKey( $params['src'] ); - if ( $src === null ) { - return null; - } - - if ( isset( $this->files[$src] ) ) { - return [ - 'mtime' => $this->files[$src]['mtime'], - 'size' => strlen( $this->files[$src]['data'] ), - ]; - } - - return false; - } - - protected function doGetLocalCopyMulti( array $params ) { - $tmpFiles = []; // (path => TempFSFile) - foreach ( $params['srcs'] as $srcPath ) { - $src = $this->resolveHashKey( $srcPath ); - if ( $src === null || !isset( $this->files[$src] ) ) { - $fsFile = null; - } else { - // Create a new temporary file with the same extension... - $ext = FileBackend::extensionFromPath( $src ); - $fsFile = TempFSFile::factory( 'localcopy_', $ext ); - if ( $fsFile ) { - $bytes = file_put_contents( $fsFile->getPath(), $this->files[$src]['data'] ); - if ( $bytes !== strlen( $this->files[$src]['data'] ) ) { - $fsFile = null; - } - } - } - $tmpFiles[$srcPath] = $fsFile; - } - - return $tmpFiles; - } - - protected function doStreamFile( array $params ) { - $status = Status::newGood(); - - $src = $this->resolveHashKey( $params['src'] ); - if ( $src === null || !isset( $this->files[$src] ) ) { - $status->fatal( 'backend-fail-stream', $params['src'] ); - - return $status; - } - - print $this->files[$src]['data']; - - return $status; - } - - protected function doDirectoryExists( $container, $dir, array $params ) { - $prefix = rtrim( "$container/$dir", '/' ) . '/'; - foreach ( $this->files as $path => $data ) { - if ( strpos( $path, $prefix ) === 0 ) { - return true; - } - } - - return false; - } - - public function getDirectoryListInternal( $container, $dir, array $params ) { - $dirs = []; - $prefix = rtrim( "$container/$dir", '/' ) . '/'; - $prefixLen = strlen( $prefix ); - foreach ( $this->files as $path => $data ) { - if ( strpos( $path, $prefix ) === 0 ) { - $relPath = substr( $path, $prefixLen ); - if ( $relPath === false ) { - continue; - } elseif ( strpos( $relPath, '/' ) === false ) { - continue; // just a file - } - $parts = array_slice( explode( '/', $relPath ), 0, -1 ); // last part is file name - if ( !empty( $params['topOnly'] ) ) { - $dirs[$parts[0]] = 1; // top directory - } else { - $current = ''; - foreach ( $parts as $part ) { // all directories - $dir = ( $current === '' ) ? $part : "$current/$part"; - $dirs[$dir] = 1; - $current = $dir; - } - } - } - } - - return array_keys( $dirs ); - } - - public function getFileListInternal( $container, $dir, array $params ) { - $files = []; - $prefix = rtrim( "$container/$dir", '/' ) . '/'; - $prefixLen = strlen( $prefix ); - foreach ( $this->files as $path => $data ) { - if ( strpos( $path, $prefix ) === 0 ) { - $relPath = substr( $path, $prefixLen ); - if ( $relPath === false ) { - continue; - } elseif ( !empty( $params['topOnly'] ) && strpos( $relPath, '/' ) !== false ) { - continue; - } - $files[] = $relPath; - } - } - - return $files; - } - - protected function directoriesAreVirtual() { - return true; - } - - /** - * Get the absolute file system path for a storage path - * - * @param string $storagePath Storage path - * @return string|null - */ - protected function resolveHashKey( $storagePath ) { - list( $fullCont, $relPath ) = $this->resolveStoragePathReal( $storagePath ); - if ( $relPath === null ) { - return null; // invalid - } - - return ( $relPath !== '' ) ? "$fullCont/$relPath" : $fullCont; - } -} diff --git a/includes/filebackend/SwiftFileBackend.php b/includes/filebackend/SwiftFileBackend.php deleted file mode 100644 index 0f7e4b569e..0000000000 --- a/includes/filebackend/SwiftFileBackend.php +++ /dev/null @@ -1,1910 +0,0 @@ -swiftAuthUrl = $config['swiftAuthUrl']; - $this->swiftUser = $config['swiftUser']; - $this->swiftKey = $config['swiftKey']; - // Optional settings - $this->authTTL = isset( $config['swiftAuthTTL'] ) - ? $config['swiftAuthTTL'] - : 15 * 60; // some sane number - $this->swiftTempUrlKey = isset( $config['swiftTempUrlKey'] ) - ? $config['swiftTempUrlKey'] - : ''; - $this->shardViaHashLevels = isset( $config['shardViaHashLevels'] ) - ? $config['shardViaHashLevels'] - : ''; - $this->rgwS3AccessKey = isset( $config['rgwS3AccessKey'] ) - ? $config['rgwS3AccessKey'] - : ''; - $this->rgwS3SecretKey = isset( $config['rgwS3SecretKey'] ) - ? $config['rgwS3SecretKey'] - : ''; - // HTTP helper client - $this->http = new MultiHttpClient( [] ); - // Cache container information to mask latency - if ( isset( $config['wanCache'] ) && $config['wanCache'] instanceof WANObjectCache ) { - $this->memCache = $config['wanCache']; - } - // Process cache for container info - $this->containerStatCache = new ProcessCacheLRU( 300 ); - // Cache auth token information to avoid RTTs - if ( !empty( $config['cacheAuthInfo'] ) ) { - if ( PHP_SAPI === 'cli' ) { - // Preferrably memcached - $this->srvCache = ObjectCache::getLocalClusterInstance(); - } else { - // Look for APC, XCache, WinCache, ect... - $this->srvCache = ObjectCache::getLocalServerInstance( CACHE_NONE ); - } - } else { - $this->srvCache = new EmptyBagOStuff(); - } - } - - public function getFeatures() { - return ( FileBackend::ATTR_UNICODE_PATHS | - FileBackend::ATTR_HEADERS | FileBackend::ATTR_METADATA ); - } - - protected function resolveContainerPath( $container, $relStoragePath ) { - if ( !mb_check_encoding( $relStoragePath, 'UTF-8' ) ) { - return null; // not UTF-8, makes it hard to use CF and the swift HTTP API - } elseif ( strlen( urlencode( $relStoragePath ) ) > 1024 ) { - return null; // too long for Swift - } - - return $relStoragePath; - } - - public function isPathUsableInternal( $storagePath ) { - list( $container, $rel ) = $this->resolveStoragePathReal( $storagePath ); - if ( $rel === null ) { - return false; // invalid - } - - return is_array( $this->getContainerStat( $container ) ); - } - - /** - * Sanitize and filter the custom headers from a $params array. - * Only allows certain "standard" Content- and X-Content- headers. - * - * @param array $params - * @return array Sanitized value of 'headers' field in $params - */ - protected function sanitizeHdrs( array $params ) { - return isset( $params['headers'] ) - ? $this->getCustomHeaders( $params['headers'] ) - : []; - - } - - /** - * @param array $rawHeaders - * @return array Custom non-metadata HTTP headers - */ - protected function getCustomHeaders( array $rawHeaders ) { - $headers = []; - - // Normalize casing, and strip out illegal headers - foreach ( $rawHeaders as $name => $value ) { - $name = strtolower( $name ); - if ( preg_match( '/^content-(type|length)$/', $name ) ) { - continue; // blacklisted - } elseif ( preg_match( '/^(x-)?content-/', $name ) ) { - $headers[$name] = $value; // allowed - } elseif ( preg_match( '/^content-(disposition)/', $name ) ) { - $headers[$name] = $value; // allowed - } - } - // By default, Swift has annoyingly low maximum header value limits - if ( isset( $headers['content-disposition'] ) ) { - $disposition = ''; - // @note: assume FileBackend::makeContentDisposition() already used - foreach ( explode( ';', $headers['content-disposition'] ) as $part ) { - $part = trim( $part ); - $new = ( $disposition === '' ) ? $part : "{$disposition};{$part}"; - if ( strlen( $new ) <= 255 ) { - $disposition = $new; - } else { - break; // too long; sigh - } - } - $headers['content-disposition'] = $disposition; - } - - return $headers; - } - - /** - * @param array $rawHeaders - * @return array Custom metadata headers - */ - protected function getMetadataHeaders( array $rawHeaders ) { - $headers = []; - foreach ( $rawHeaders as $name => $value ) { - $name = strtolower( $name ); - if ( strpos( $name, 'x-object-meta-' ) === 0 ) { - $headers[$name] = $value; - } - } - - return $headers; - } - - /** - * @param array $rawHeaders - * @return array Custom metadata headers with prefix removed - */ - protected function getMetadata( array $rawHeaders ) { - $metadata = []; - foreach ( $this->getMetadataHeaders( $rawHeaders ) as $name => $value ) { - $metadata[substr( $name, strlen( 'x-object-meta-' ) )] = $value; - } - - return $metadata; - } - - protected function doCreateInternal( array $params ) { - $status = Status::newGood(); - - list( $dstCont, $dstRel ) = $this->resolveStoragePathReal( $params['dst'] ); - if ( $dstRel === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['dst'] ); - - return $status; - } - - $sha1Hash = Wikimedia\base_convert( sha1( $params['content'] ), 16, 36, 31 ); - $contentType = isset( $params['headers']['content-type'] ) - ? $params['headers']['content-type'] - : $this->getContentType( $params['dst'], $params['content'], null ); - - $reqs = [ [ - 'method' => 'PUT', - 'url' => [ $dstCont, $dstRel ], - 'headers' => [ - 'content-length' => strlen( $params['content'] ), - 'etag' => md5( $params['content'] ), - 'content-type' => $contentType, - 'x-object-meta-sha1base36' => $sha1Hash - ] + $this->sanitizeHdrs( $params ), - 'body' => $params['content'] - ] ]; - - $method = __METHOD__; - $handler = function ( array $request, Status $status ) use ( $method, $params ) { - list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response']; - if ( $rcode === 201 ) { - // good - } elseif ( $rcode === 412 ) { - $status->fatal( 'backend-fail-contenttype', $params['dst'] ); - } else { - $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc ); - } - }; - - $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs ); - if ( !empty( $params['async'] ) ) { // deferred - $status->value = $opHandle; - } else { // actually write the object in Swift - $status->merge( current( $this->doExecuteOpHandlesInternal( [ $opHandle ] ) ) ); - } - - return $status; - } - - protected function doStoreInternal( array $params ) { - $status = Status::newGood(); - - list( $dstCont, $dstRel ) = $this->resolveStoragePathReal( $params['dst'] ); - if ( $dstRel === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['dst'] ); - - return $status; - } - - MediaWiki\suppressWarnings(); - $sha1Hash = sha1_file( $params['src'] ); - MediaWiki\restoreWarnings(); - if ( $sha1Hash === false ) { // source doesn't exist? - $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] ); - - return $status; - } - $sha1Hash = Wikimedia\base_convert( $sha1Hash, 16, 36, 31 ); - $contentType = isset( $params['headers']['content-type'] ) - ? $params['headers']['content-type'] - : $this->getContentType( $params['dst'], null, $params['src'] ); - - $handle = fopen( $params['src'], 'rb' ); - if ( $handle === false ) { // source doesn't exist? - $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] ); - - return $status; - } - - $reqs = [ [ - 'method' => 'PUT', - 'url' => [ $dstCont, $dstRel ], - 'headers' => [ - 'content-length' => filesize( $params['src'] ), - 'etag' => md5_file( $params['src'] ), - 'content-type' => $contentType, - 'x-object-meta-sha1base36' => $sha1Hash - ] + $this->sanitizeHdrs( $params ), - 'body' => $handle // resource - ] ]; - - $method = __METHOD__; - $handler = function ( array $request, Status $status ) use ( $method, $params ) { - list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response']; - if ( $rcode === 201 ) { - // good - } elseif ( $rcode === 412 ) { - $status->fatal( 'backend-fail-contenttype', $params['dst'] ); - } else { - $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc ); - } - }; - - $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs ); - if ( !empty( $params['async'] ) ) { // deferred - $status->value = $opHandle; - } else { // actually write the object in Swift - $status->merge( current( $this->doExecuteOpHandlesInternal( [ $opHandle ] ) ) ); - } - - return $status; - } - - protected function doCopyInternal( array $params ) { - $status = Status::newGood(); - - list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] ); - if ( $srcRel === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['src'] ); - - return $status; - } - - list( $dstCont, $dstRel ) = $this->resolveStoragePathReal( $params['dst'] ); - if ( $dstRel === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['dst'] ); - - return $status; - } - - $reqs = [ [ - 'method' => 'PUT', - 'url' => [ $dstCont, $dstRel ], - 'headers' => [ - 'x-copy-from' => '/' . rawurlencode( $srcCont ) . - '/' . str_replace( "%2F", "/", rawurlencode( $srcRel ) ) - ] + $this->sanitizeHdrs( $params ), // extra headers merged into object - ] ]; - - $method = __METHOD__; - $handler = function ( array $request, Status $status ) use ( $method, $params ) { - list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response']; - if ( $rcode === 201 ) { - // good - } elseif ( $rcode === 404 ) { - $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] ); - } else { - $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc ); - } - }; - - $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs ); - if ( !empty( $params['async'] ) ) { // deferred - $status->value = $opHandle; - } else { // actually write the object in Swift - $status->merge( current( $this->doExecuteOpHandlesInternal( [ $opHandle ] ) ) ); - } - - return $status; - } - - protected function doMoveInternal( array $params ) { - $status = Status::newGood(); - - list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] ); - if ( $srcRel === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['src'] ); - - return $status; - } - - list( $dstCont, $dstRel ) = $this->resolveStoragePathReal( $params['dst'] ); - if ( $dstRel === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['dst'] ); - - return $status; - } - - $reqs = [ - [ - 'method' => 'PUT', - 'url' => [ $dstCont, $dstRel ], - 'headers' => [ - 'x-copy-from' => '/' . rawurlencode( $srcCont ) . - '/' . str_replace( "%2F", "/", rawurlencode( $srcRel ) ) - ] + $this->sanitizeHdrs( $params ) // extra headers merged into object - ] - ]; - if ( "{$srcCont}/{$srcRel}" !== "{$dstCont}/{$dstRel}" ) { - $reqs[] = [ - 'method' => 'DELETE', - 'url' => [ $srcCont, $srcRel ], - 'headers' => [] - ]; - } - - $method = __METHOD__; - $handler = function ( array $request, Status $status ) use ( $method, $params ) { - list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response']; - if ( $request['method'] === 'PUT' && $rcode === 201 ) { - // good - } elseif ( $request['method'] === 'DELETE' && $rcode === 204 ) { - // good - } elseif ( $rcode === 404 ) { - $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] ); - } else { - $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc ); - } - }; - - $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs ); - if ( !empty( $params['async'] ) ) { // deferred - $status->value = $opHandle; - } else { // actually move the object in Swift - $status->merge( current( $this->doExecuteOpHandlesInternal( [ $opHandle ] ) ) ); - } - - return $status; - } - - protected function doDeleteInternal( array $params ) { - $status = Status::newGood(); - - list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] ); - if ( $srcRel === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['src'] ); - - return $status; - } - - $reqs = [ [ - 'method' => 'DELETE', - 'url' => [ $srcCont, $srcRel ], - 'headers' => [] - ] ]; - - $method = __METHOD__; - $handler = function ( array $request, Status $status ) use ( $method, $params ) { - list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response']; - if ( $rcode === 204 ) { - // good - } elseif ( $rcode === 404 ) { - if ( empty( $params['ignoreMissingSource'] ) ) { - $status->fatal( 'backend-fail-delete', $params['src'] ); - } - } else { - $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc ); - } - }; - - $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs ); - if ( !empty( $params['async'] ) ) { // deferred - $status->value = $opHandle; - } else { // actually delete the object in Swift - $status->merge( current( $this->doExecuteOpHandlesInternal( [ $opHandle ] ) ) ); - } - - return $status; - } - - protected function doDescribeInternal( array $params ) { - $status = Status::newGood(); - - list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] ); - if ( $srcRel === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['src'] ); - - return $status; - } - - // Fetch the old object headers/metadata...this should be in stat cache by now - $stat = $this->getFileStat( [ 'src' => $params['src'], 'latest' => 1 ] ); - if ( $stat && !isset( $stat['xattr'] ) ) { // older cache entry - $stat = $this->doGetFileStat( [ 'src' => $params['src'], 'latest' => 1 ] ); - } - if ( !$stat ) { - $status->fatal( 'backend-fail-describe', $params['src'] ); - - return $status; - } - - // POST clears prior headers, so we need to merge the changes in to the old ones - $metaHdrs = []; - foreach ( $stat['xattr']['metadata'] as $name => $value ) { - $metaHdrs["x-object-meta-$name"] = $value; - } - $customHdrs = $this->sanitizeHdrs( $params ) + $stat['xattr']['headers']; - - $reqs = [ [ - 'method' => 'POST', - 'url' => [ $srcCont, $srcRel ], - 'headers' => $metaHdrs + $customHdrs - ] ]; - - $method = __METHOD__; - $handler = function ( array $request, Status $status ) use ( $method, $params ) { - list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response']; - if ( $rcode === 202 ) { - // good - } elseif ( $rcode === 404 ) { - $status->fatal( 'backend-fail-describe', $params['src'] ); - } else { - $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc ); - } - }; - - $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs ); - if ( !empty( $params['async'] ) ) { // deferred - $status->value = $opHandle; - } else { // actually change the object in Swift - $status->merge( current( $this->doExecuteOpHandlesInternal( [ $opHandle ] ) ) ); - } - - return $status; - } - - protected function doPrepareInternal( $fullCont, $dir, array $params ) { - $status = Status::newGood(); - - // (a) Check if container already exists - $stat = $this->getContainerStat( $fullCont ); - if ( is_array( $stat ) ) { - return $status; // already there - } elseif ( $stat === null ) { - $status->fatal( 'backend-fail-internal', $this->name ); - wfDebugLog( 'SwiftBackend', __METHOD__ . ': cannot get container stat' ); - - return $status; - } - - // (b) Create container as needed with proper ACLs - if ( $stat === false ) { - $params['op'] = 'prepare'; - $status->merge( $this->createContainer( $fullCont, $params ) ); - } - - return $status; - } - - protected function doSecureInternal( $fullCont, $dir, array $params ) { - $status = Status::newGood(); - if ( empty( $params['noAccess'] ) ) { - return $status; // nothing to do - } - - $stat = $this->getContainerStat( $fullCont ); - if ( is_array( $stat ) ) { - // Make container private to end-users... - $status->merge( $this->setContainerAccess( - $fullCont, - [ $this->swiftUser ], // read - [ $this->swiftUser ] // write - ) ); - } elseif ( $stat === false ) { - $status->fatal( 'backend-fail-usable', $params['dir'] ); - } else { - $status->fatal( 'backend-fail-internal', $this->name ); - wfDebugLog( 'SwiftBackend', __METHOD__ . ': cannot get container stat' ); - } - - return $status; - } - - protected function doPublishInternal( $fullCont, $dir, array $params ) { - $status = Status::newGood(); - - $stat = $this->getContainerStat( $fullCont ); - if ( is_array( $stat ) ) { - // Make container public to end-users... - $status->merge( $this->setContainerAccess( - $fullCont, - [ $this->swiftUser, '.r:*' ], // read - [ $this->swiftUser ] // write - ) ); - } elseif ( $stat === false ) { - $status->fatal( 'backend-fail-usable', $params['dir'] ); - } else { - $status->fatal( 'backend-fail-internal', $this->name ); - wfDebugLog( 'SwiftBackend', __METHOD__ . ': cannot get container stat' ); - } - - return $status; - } - - protected function doCleanInternal( $fullCont, $dir, array $params ) { - $status = Status::newGood(); - - // Only containers themselves can be removed, all else is virtual - if ( $dir != '' ) { - return $status; // nothing to do - } - - // (a) Check the container - $stat = $this->getContainerStat( $fullCont, true ); - if ( $stat === false ) { - return $status; // ok, nothing to do - } elseif ( !is_array( $stat ) ) { - $status->fatal( 'backend-fail-internal', $this->name ); - wfDebugLog( 'SwiftBackend', __METHOD__ . ': cannot get container stat' ); - - return $status; - } - - // (b) Delete the container if empty - if ( $stat['count'] == 0 ) { - $params['op'] = 'clean'; - $status->merge( $this->deleteContainer( $fullCont, $params ) ); - } - - return $status; - } - - protected function doGetFileStat( array $params ) { - $params = [ 'srcs' => [ $params['src'] ], 'concurrency' => 1 ] + $params; - unset( $params['src'] ); - $stats = $this->doGetFileStatMulti( $params ); - - return reset( $stats ); - } - - /** - * Convert dates like "Tue, 03 Jan 2012 22:01:04 GMT"/"2013-05-11T07:37:27.678360Z". - * Dates might also come in like "2013-05-11T07:37:27.678360" from Swift listings, - * missing the timezone suffix (though Ceph RGW does not appear to have this bug). - * - * @param string $ts - * @param int $format Output format (TS_* constant) - * @return string - * @throws FileBackendError - */ - protected function convertSwiftDate( $ts, $format = TS_MW ) { - try { - $timestamp = new MWTimestamp( $ts ); - - return $timestamp->getTimestamp( $format ); - } catch ( Exception $e ) { - throw new FileBackendError( $e->getMessage() ); - } - } - - /** - * Fill in any missing object metadata and save it to Swift - * - * @param array $objHdrs Object response headers - * @param string $path Storage path to object - * @return array New headers - */ - protected function addMissingMetadata( array $objHdrs, $path ) { - if ( isset( $objHdrs['x-object-meta-sha1base36'] ) ) { - return $objHdrs; // nothing to do - } - - /** @noinspection PhpUnusedLocalVariableInspection */ - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - wfDebugLog( 'SwiftBackend', __METHOD__ . ": $path was not stored with SHA-1 metadata." ); - - $objHdrs['x-object-meta-sha1base36'] = false; - - $auth = $this->getAuthentication(); - if ( !$auth ) { - return $objHdrs; // failed - } - - // Find prior custom HTTP headers - $postHeaders = $this->getCustomHeaders( $objHdrs ); - // Find prior metadata headers - $postHeaders += $this->getMetadataHeaders( $objHdrs ); - - $status = Status::newGood(); - /** @noinspection PhpUnusedLocalVariableInspection */ - $scopeLockS = $this->getScopedFileLocks( [ $path ], LockManager::LOCK_UW, $status ); - if ( $status->isOK() ) { - $tmpFile = $this->getLocalCopy( [ 'src' => $path, 'latest' => 1 ] ); - if ( $tmpFile ) { - $hash = $tmpFile->getSha1Base36(); - if ( $hash !== false ) { - $objHdrs['x-object-meta-sha1base36'] = $hash; - // Merge new SHA1 header into the old ones - $postHeaders['x-object-meta-sha1base36'] = $hash; - list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $path ); - list( $rcode ) = $this->http->run( [ - 'method' => 'POST', - 'url' => $this->storageUrl( $auth, $srcCont, $srcRel ), - 'headers' => $this->authTokenHeaders( $auth ) + $postHeaders - ] ); - if ( $rcode >= 200 && $rcode <= 299 ) { - $this->deleteFileCache( $path ); - - return $objHdrs; // success - } - } - } - } - - wfDebugLog( 'SwiftBackend', __METHOD__ . ": unable to set SHA-1 metadata for $path" ); - - return $objHdrs; // failed - } - - protected function doGetFileContentsMulti( array $params ) { - $contents = []; - - $auth = $this->getAuthentication(); - - $ep = array_diff_key( $params, [ 'srcs' => 1 ] ); // for error logging - // Blindly create tmp files and stream to them, catching any exception if the file does - // not exist. Doing stats here is useless and will loop infinitely in addMissingMetadata(). - $reqs = []; // (path => op) - - foreach ( $params['srcs'] as $path ) { // each path in this concurrent batch - list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $path ); - if ( $srcRel === null || !$auth ) { - $contents[$path] = false; - continue; - } - // Create a new temporary memory file... - $handle = fopen( 'php://temp', 'wb' ); - if ( $handle ) { - $reqs[$path] = [ - 'method' => 'GET', - 'url' => $this->storageUrl( $auth, $srcCont, $srcRel ), - 'headers' => $this->authTokenHeaders( $auth ) - + $this->headersFromParams( $params ), - 'stream' => $handle, - ]; - } - $contents[$path] = false; - } - - $opts = [ 'maxConnsPerHost' => $params['concurrency'] ]; - $reqs = $this->http->runMulti( $reqs, $opts ); - foreach ( $reqs as $path => $op ) { - list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $op['response']; - if ( $rcode >= 200 && $rcode <= 299 ) { - rewind( $op['stream'] ); // start from the beginning - $contents[$path] = stream_get_contents( $op['stream'] ); - } elseif ( $rcode === 404 ) { - $contents[$path] = false; - } else { - $this->onError( null, __METHOD__, - [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc ); - } - fclose( $op['stream'] ); // close open handle - } - - return $contents; - } - - protected function doDirectoryExists( $fullCont, $dir, array $params ) { - $prefix = ( $dir == '' ) ? null : "{$dir}/"; - $status = $this->objectListing( $fullCont, 'names', 1, null, $prefix ); - if ( $status->isOK() ) { - return ( count( $status->value ) ) > 0; - } - - return null; // error - } - - /** - * @see FileBackendStore::getDirectoryListInternal() - * @param string $fullCont - * @param string $dir - * @param array $params - * @return SwiftFileBackendDirList - */ - public function getDirectoryListInternal( $fullCont, $dir, array $params ) { - return new SwiftFileBackendDirList( $this, $fullCont, $dir, $params ); - } - - /** - * @see FileBackendStore::getFileListInternal() - * @param string $fullCont - * @param string $dir - * @param array $params - * @return SwiftFileBackendFileList - */ - public function getFileListInternal( $fullCont, $dir, array $params ) { - return new SwiftFileBackendFileList( $this, $fullCont, $dir, $params ); - } - - /** - * Do not call this function outside of SwiftFileBackendFileList - * - * @param string $fullCont Resolved container name - * @param string $dir Resolved storage directory with no trailing slash - * @param string|null $after Resolved container relative path to list items after - * @param int $limit Max number of items to list - * @param array $params Parameters for getDirectoryList() - * @return array List of container relative resolved paths of directories directly under $dir - * @throws FileBackendError - */ - public function getDirListPageInternal( $fullCont, $dir, &$after, $limit, array $params ) { - $dirs = []; - if ( $after === INF ) { - return $dirs; // nothing more - } - - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - - $prefix = ( $dir == '' ) ? null : "{$dir}/"; - // Non-recursive: only list dirs right under $dir - if ( !empty( $params['topOnly'] ) ) { - $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix, '/' ); - if ( !$status->isOK() ) { - throw new FileBackendError( "Iterator page I/O error: {$status->getMessage()}" ); - } - $objects = $status->value; - foreach ( $objects as $object ) { // files and directories - if ( substr( $object, -1 ) === '/' ) { - $dirs[] = $object; // directories end in '/' - } - } - } else { - // Recursive: list all dirs under $dir and its subdirs - $getParentDir = function ( $path ) { - return ( strpos( $path, '/' ) !== false ) ? dirname( $path ) : false; - }; - - // Get directory from last item of prior page - $lastDir = $getParentDir( $after ); // must be first page - $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix ); - - if ( !$status->isOK() ) { - throw new FileBackendError( "Iterator page I/O error: {$status->getMessage()}" ); - } - - $objects = $status->value; - - foreach ( $objects as $object ) { // files - $objectDir = $getParentDir( $object ); // directory of object - - if ( $objectDir !== false && $objectDir !== $dir ) { - // Swift stores paths in UTF-8, using binary sorting. - // See function "create_container_table" in common/db.py. - // If a directory is not "greater" than the last one, - // then it was already listed by the calling iterator. - if ( strcmp( $objectDir, $lastDir ) > 0 ) { - $pDir = $objectDir; - do { // add dir and all its parent dirs - $dirs[] = "{$pDir}/"; - $pDir = $getParentDir( $pDir ); - } while ( $pDir !== false // sanity - && strcmp( $pDir, $lastDir ) > 0 // not done already - && strlen( $pDir ) > strlen( $dir ) // within $dir - ); - } - $lastDir = $objectDir; - } - } - } - // Page on the unfiltered directory listing (what is returned may be filtered) - if ( count( $objects ) < $limit ) { - $after = INF; // avoid a second RTT - } else { - $after = end( $objects ); // update last item - } - - return $dirs; - } - - /** - * Do not call this function outside of SwiftFileBackendFileList - * - * @param string $fullCont Resolved container name - * @param string $dir Resolved storage directory with no trailing slash - * @param string|null $after Resolved container relative path of file to list items after - * @param int $limit Max number of items to list - * @param array $params Parameters for getDirectoryList() - * @return array List of resolved container relative paths of files under $dir - * @throws FileBackendError - */ - public function getFileListPageInternal( $fullCont, $dir, &$after, $limit, array $params ) { - $files = []; // list of (path, stat array or null) entries - if ( $after === INF ) { - return $files; // nothing more - } - - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - - $prefix = ( $dir == '' ) ? null : "{$dir}/"; - // $objects will contain a list of unfiltered names or CF_Object items - // Non-recursive: only list files right under $dir - if ( !empty( $params['topOnly'] ) ) { - if ( !empty( $params['adviseStat'] ) ) { - $status = $this->objectListing( $fullCont, 'info', $limit, $after, $prefix, '/' ); - } else { - $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix, '/' ); - } - } else { - // Recursive: list all files under $dir and its subdirs - if ( !empty( $params['adviseStat'] ) ) { - $status = $this->objectListing( $fullCont, 'info', $limit, $after, $prefix ); - } else { - $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix ); - } - } - - // Reformat this list into a list of (name, stat array or null) entries - if ( !$status->isOK() ) { - throw new FileBackendError( "Iterator page I/O error: {$status->getMessage()}" ); - } - - $objects = $status->value; - $files = $this->buildFileObjectListing( $params, $dir, $objects ); - - // Page on the unfiltered object listing (what is returned may be filtered) - if ( count( $objects ) < $limit ) { - $after = INF; // avoid a second RTT - } else { - $after = end( $objects ); // update last item - $after = is_object( $after ) ? $after->name : $after; - } - - return $files; - } - - /** - * Build a list of file objects, filtering out any directories - * and extracting any stat info if provided in $objects (for CF_Objects) - * - * @param array $params Parameters for getDirectoryList() - * @param string $dir Resolved container directory path - * @param array $objects List of CF_Object items or object names - * @return array List of (names,stat array or null) entries - */ - private function buildFileObjectListing( array $params, $dir, array $objects ) { - $names = []; - foreach ( $objects as $object ) { - if ( is_object( $object ) ) { - if ( isset( $object->subdir ) || !isset( $object->name ) ) { - continue; // virtual directory entry; ignore - } - $stat = [ - // Convert various random Swift dates to TS_MW - 'mtime' => $this->convertSwiftDate( $object->last_modified, TS_MW ), - 'size' => (int)$object->bytes, - 'sha1' => null, - // Note: manifiest ETags are not an MD5 of the file - 'md5' => ctype_xdigit( $object->hash ) ? $object->hash : null, - 'latest' => false // eventually consistent - ]; - $names[] = [ $object->name, $stat ]; - } elseif ( substr( $object, -1 ) !== '/' ) { - // Omit directories, which end in '/' in listings - $names[] = [ $object, null ]; - } - } - - return $names; - } - - /** - * Do not call this function outside of SwiftFileBackendFileList - * - * @param string $path Storage path - * @param array $val Stat value - */ - public function loadListingStatInternal( $path, array $val ) { - $this->cheapCache->set( $path, 'stat', $val ); - } - - protected function doGetFileXAttributes( array $params ) { - $stat = $this->getFileStat( $params ); - if ( $stat ) { - if ( !isset( $stat['xattr'] ) ) { - // Stat entries filled by file listings don't include metadata/headers - $this->clearCache( [ $params['src'] ] ); - $stat = $this->getFileStat( $params ); - } - - return $stat['xattr']; - } else { - return false; - } - } - - protected function doGetFileSha1base36( array $params ) { - $stat = $this->getFileStat( $params ); - if ( $stat ) { - if ( !isset( $stat['sha1'] ) ) { - // Stat entries filled by file listings don't include SHA1 - $this->clearCache( [ $params['src'] ] ); - $stat = $this->getFileStat( $params ); - } - - return $stat['sha1']; - } else { - return false; - } - } - - protected function doStreamFile( array $params ) { - $status = Status::newGood(); - - list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] ); - if ( $srcRel === null ) { - $status->fatal( 'backend-fail-invalidpath', $params['src'] ); - } - - $auth = $this->getAuthentication(); - if ( !$auth || !is_array( $this->getContainerStat( $srcCont ) ) ) { - $status->fatal( 'backend-fail-stream', $params['src'] ); - - return $status; - } - - $handle = fopen( 'php://output', 'wb' ); - - list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [ - 'method' => 'GET', - 'url' => $this->storageUrl( $auth, $srcCont, $srcRel ), - 'headers' => $this->authTokenHeaders( $auth ) - + $this->headersFromParams( $params ), - 'stream' => $handle, - ] ); - - if ( $rcode >= 200 && $rcode <= 299 ) { - // good - } elseif ( $rcode === 404 ) { - $status->fatal( 'backend-fail-stream', $params['src'] ); - } else { - $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc ); - } - - return $status; - } - - protected function doGetLocalCopyMulti( array $params ) { - $tmpFiles = []; - - $auth = $this->getAuthentication(); - - $ep = array_diff_key( $params, [ 'srcs' => 1 ] ); // for error logging - // Blindly create tmp files and stream to them, catching any exception if the file does - // not exist. Doing a stat here is useless causes infinite loops in addMissingMetadata(). - $reqs = []; // (path => op) - - foreach ( $params['srcs'] as $path ) { // each path in this concurrent batch - list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $path ); - if ( $srcRel === null || !$auth ) { - $tmpFiles[$path] = null; - continue; - } - // Get source file extension - $ext = FileBackend::extensionFromPath( $path ); - // Create a new temporary file... - $tmpFile = TempFSFile::factory( 'localcopy_', $ext ); - if ( $tmpFile ) { - $handle = fopen( $tmpFile->getPath(), 'wb' ); - if ( $handle ) { - $reqs[$path] = [ - 'method' => 'GET', - 'url' => $this->storageUrl( $auth, $srcCont, $srcRel ), - 'headers' => $this->authTokenHeaders( $auth ) - + $this->headersFromParams( $params ), - 'stream' => $handle, - ]; - } else { - $tmpFile = null; - } - } - $tmpFiles[$path] = $tmpFile; - } - - $isLatest = ( $this->isRGW || !empty( $params['latest'] ) ); - $opts = [ 'maxConnsPerHost' => $params['concurrency'] ]; - $reqs = $this->http->runMulti( $reqs, $opts ); - foreach ( $reqs as $path => $op ) { - list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $op['response']; - fclose( $op['stream'] ); // close open handle - if ( $rcode >= 200 && $rcode <= 299 ) { - $size = $tmpFiles[$path] ? $tmpFiles[$path]->getSize() : 0; - // Double check that the disk is not full/broken - if ( $size != $rhdrs['content-length'] ) { - $tmpFiles[$path] = null; - $rerr = "Got {$size}/{$rhdrs['content-length']} bytes"; - $this->onError( null, __METHOD__, - [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc ); - } - // Set the file stat process cache in passing - $stat = $this->getStatFromHeaders( $rhdrs ); - $stat['latest'] = $isLatest; - $this->cheapCache->set( $path, 'stat', $stat ); - } elseif ( $rcode === 404 ) { - $tmpFiles[$path] = false; - } else { - $tmpFiles[$path] = null; - $this->onError( null, __METHOD__, - [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc ); - } - } - - return $tmpFiles; - } - - public function getFileHttpUrl( array $params ) { - if ( $this->swiftTempUrlKey != '' || - ( $this->rgwS3AccessKey != '' && $this->rgwS3SecretKey != '' ) - ) { - list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] ); - if ( $srcRel === null ) { - return null; // invalid path - } - - $auth = $this->getAuthentication(); - if ( !$auth ) { - return null; - } - - $ttl = isset( $params['ttl'] ) ? $params['ttl'] : 86400; - $expires = time() + $ttl; - - if ( $this->swiftTempUrlKey != '' ) { - $url = $this->storageUrl( $auth, $srcCont, $srcRel ); - // Swift wants the signature based on the unencoded object name - $contPath = parse_url( $this->storageUrl( $auth, $srcCont ), PHP_URL_PATH ); - $signature = hash_hmac( 'sha1', - "GET\n{$expires}\n{$contPath}/{$srcRel}", - $this->swiftTempUrlKey - ); - - return "{$url}?temp_url_sig={$signature}&temp_url_expires={$expires}"; - } else { // give S3 API URL for rgw - // Path for signature starts with the bucket - $spath = '/' . rawurlencode( $srcCont ) . '/' . - str_replace( '%2F', '/', rawurlencode( $srcRel ) ); - // Calculate the hash - $signature = base64_encode( hash_hmac( - 'sha1', - "GET\n\n\n{$expires}\n{$spath}", - $this->rgwS3SecretKey, - true // raw - ) ); - // See http://s3.amazonaws.com/doc/s3-developer-guide/RESTAuthentication.html. - // Note: adding a newline for empty CanonicalizedAmzHeaders does not work. - return wfAppendQuery( - str_replace( '/swift/v1', '', // S3 API is the rgw default - $this->storageUrl( $auth ) . $spath ), - [ - 'Signature' => $signature, - 'Expires' => $expires, - 'AWSAccessKeyId' => $this->rgwS3AccessKey ] - ); - } - } - - return null; - } - - protected function directoriesAreVirtual() { - return true; - } - - /** - * Get headers to send to Swift when reading a file based - * on a FileBackend params array, e.g. that of getLocalCopy(). - * $params is currently only checked for a 'latest' flag. - * - * @param array $params - * @return array - */ - protected function headersFromParams( array $params ) { - $hdrs = []; - if ( !empty( $params['latest'] ) ) { - $hdrs['x-newest'] = 'true'; - } - - return $hdrs; - } - - /** - * @param FileBackendStoreOpHandle[] $fileOpHandles - * - * @return Status[] - */ - protected function doExecuteOpHandlesInternal( array $fileOpHandles ) { - $statuses = []; - - $auth = $this->getAuthentication(); - if ( !$auth ) { - foreach ( $fileOpHandles as $index => $fileOpHandle ) { - $statuses[$index] = Status::newFatal( 'backend-fail-connect', $this->name ); - } - - return $statuses; - } - - // Split the HTTP requests into stages that can be done concurrently - $httpReqsByStage = []; // map of (stage => index => HTTP request) - foreach ( $fileOpHandles as $index => $fileOpHandle ) { - $reqs = $fileOpHandle->httpOp; - // Convert the 'url' parameter to an actual URL using $auth - foreach ( $reqs as $stage => &$req ) { - list( $container, $relPath ) = $req['url']; - $req['url'] = $this->storageUrl( $auth, $container, $relPath ); - $req['headers'] = isset( $req['headers'] ) ? $req['headers'] : []; - $req['headers'] = $this->authTokenHeaders( $auth ) + $req['headers']; - $httpReqsByStage[$stage][$index] = $req; - } - $statuses[$index] = Status::newGood(); - } - - // Run all requests for the first stage, then the next, and so on - $reqCount = count( $httpReqsByStage ); - for ( $stage = 0; $stage < $reqCount; ++$stage ) { - $httpReqs = $this->http->runMulti( $httpReqsByStage[$stage] ); - foreach ( $httpReqs as $index => $httpReq ) { - // Run the callback for each request of this operation - $callback = $fileOpHandles[$index]->callback; - call_user_func_array( $callback, [ $httpReq, $statuses[$index] ] ); - // On failure, abort all remaining requests for this operation - // (e.g. abort the DELETE request if the COPY request fails for a move) - if ( !$statuses[$index]->isOK() ) { - $stages = count( $fileOpHandles[$index]->httpOp ); - for ( $s = ( $stage + 1 ); $s < $stages; ++$s ) { - unset( $httpReqsByStage[$s][$index] ); - } - } - } - } - - return $statuses; - } - - /** - * Set read/write permissions for a Swift container. - * - * @see http://swift.openstack.org/misc.html#acls - * - * In general, we don't allow listings to end-users. It's not useful, isn't well-defined - * (lists are truncated to 10000 item with no way to page), and is just a performance risk. - * - * @param string $container Resolved Swift container - * @param array $readGrps List of the possible criteria for a request to have - * access to read a container. Each item is one of the following formats: - * - account:user : Grants access if the request is by the given user - * - ".r:" : Grants access if the request is from a referrer host that - * matches the expression and the request is not for a listing. - * Setting this to '*' effectively makes a container public. - * -".rlistings:" : Grants access if the request is from a referrer host that - * matches the expression and the request is for a listing. - * @param array $writeGrps A list of the possible criteria for a request to have - * access to write to a container. Each item is of the following format: - * - account:user : Grants access if the request is by the given user - * @return Status - */ - protected function setContainerAccess( $container, array $readGrps, array $writeGrps ) { - $status = Status::newGood(); - $auth = $this->getAuthentication(); - - if ( !$auth ) { - $status->fatal( 'backend-fail-connect', $this->name ); - - return $status; - } - - list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [ - 'method' => 'POST', - 'url' => $this->storageUrl( $auth, $container ), - 'headers' => $this->authTokenHeaders( $auth ) + [ - 'x-container-read' => implode( ',', $readGrps ), - 'x-container-write' => implode( ',', $writeGrps ) - ] - ] ); - - if ( $rcode != 204 && $rcode !== 202 ) { - $status->fatal( 'backend-fail-internal', $this->name ); - wfDebugLog( 'SwiftBackend', __METHOD__ . ': unexpected rcode value (' . $rcode . ')' ); - } - - return $status; - } - - /** - * Get a Swift container stat array, possibly from process cache. - * Use $reCache if the file count or byte count is needed. - * - * @param string $container Container name - * @param bool $bypassCache Bypass all caches and load from Swift - * @return array|bool|null False on 404, null on failure - */ - protected function getContainerStat( $container, $bypassCache = false ) { - $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); - - if ( $bypassCache ) { // purge cache - $this->containerStatCache->clear( $container ); - } elseif ( !$this->containerStatCache->has( $container, 'stat' ) ) { - $this->primeContainerCache( [ $container ] ); // check persistent cache - } - if ( !$this->containerStatCache->has( $container, 'stat' ) ) { - $auth = $this->getAuthentication(); - if ( !$auth ) { - return null; - } - - list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [ - 'method' => 'HEAD', - 'url' => $this->storageUrl( $auth, $container ), - 'headers' => $this->authTokenHeaders( $auth ) - ] ); - - if ( $rcode === 204 ) { - $stat = [ - 'count' => $rhdrs['x-container-object-count'], - 'bytes' => $rhdrs['x-container-bytes-used'] - ]; - if ( $bypassCache ) { - return $stat; - } else { - $this->containerStatCache->set( $container, 'stat', $stat ); // cache it - $this->setContainerCache( $container, $stat ); // update persistent cache - } - } elseif ( $rcode === 404 ) { - return false; - } else { - $this->onError( null, __METHOD__, - [ 'cont' => $container ], $rerr, $rcode, $rdesc ); - - return null; - } - } - - return $this->containerStatCache->get( $container, 'stat' ); - } - - /** - * Create a Swift container - * - * @param string $container Container name - * @param array $params - * @return Status - */ - protected function createContainer( $container, array $params ) { - $status = Status::newGood(); - - $auth = $this->getAuthentication(); - if ( !$auth ) { - $status->fatal( 'backend-fail-connect', $this->name ); - - return $status; - } - - // @see SwiftFileBackend::setContainerAccess() - if ( empty( $params['noAccess'] ) ) { - $readGrps = [ '.r:*', $this->swiftUser ]; // public - } else { - $readGrps = [ $this->swiftUser ]; // private - } - $writeGrps = [ $this->swiftUser ]; // sanity - - list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [ - 'method' => 'PUT', - 'url' => $this->storageUrl( $auth, $container ), - 'headers' => $this->authTokenHeaders( $auth ) + [ - 'x-container-read' => implode( ',', $readGrps ), - 'x-container-write' => implode( ',', $writeGrps ) - ] - ] ); - - if ( $rcode === 201 ) { // new - // good - } elseif ( $rcode === 202 ) { // already there - // this shouldn't really happen, but is OK - } else { - $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc ); - } - - return $status; - } - - /** - * Delete a Swift container - * - * @param string $container Container name - * @param array $params - * @return Status - */ - protected function deleteContainer( $container, array $params ) { - $status = Status::newGood(); - - $auth = $this->getAuthentication(); - if ( !$auth ) { - $status->fatal( 'backend-fail-connect', $this->name ); - - return $status; - } - - list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [ - 'method' => 'DELETE', - 'url' => $this->storageUrl( $auth, $container ), - 'headers' => $this->authTokenHeaders( $auth ) - ] ); - - if ( $rcode >= 200 && $rcode <= 299 ) { // deleted - $this->containerStatCache->clear( $container ); // purge - } elseif ( $rcode === 404 ) { // not there - // this shouldn't really happen, but is OK - } elseif ( $rcode === 409 ) { // not empty - $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc ); // race? - } else { - $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc ); - } - - return $status; - } - - /** - * Get a list of objects under a container. - * Either just the names or a list of stdClass objects with details can be returned. - * - * @param string $fullCont - * @param string $type ('info' for a list of object detail maps, 'names' for names only) - * @param int $limit - * @param string|null $after - * @param string|null $prefix - * @param string|null $delim - * @return Status With the list as value - */ - private function objectListing( - $fullCont, $type, $limit, $after = null, $prefix = null, $delim = null - ) { - $status = Status::newGood(); - - $auth = $this->getAuthentication(); - if ( !$auth ) { - $status->fatal( 'backend-fail-connect', $this->name ); - - return $status; - } - - $query = [ 'limit' => $limit ]; - if ( $type === 'info' ) { - $query['format'] = 'json'; - } - if ( $after !== null ) { - $query['marker'] = $after; - } - if ( $prefix !== null ) { - $query['prefix'] = $prefix; - } - if ( $delim !== null ) { - $query['delimiter'] = $delim; - } - - list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [ - 'method' => 'GET', - 'url' => $this->storageUrl( $auth, $fullCont ), - 'query' => $query, - 'headers' => $this->authTokenHeaders( $auth ) - ] ); - - $params = [ 'cont' => $fullCont, 'prefix' => $prefix, 'delim' => $delim ]; - if ( $rcode === 200 ) { // good - if ( $type === 'info' ) { - $status->value = FormatJson::decode( trim( $rbody ) ); - } else { - $status->value = explode( "\n", trim( $rbody ) ); - } - } elseif ( $rcode === 204 ) { - $status->value = []; // empty container - } elseif ( $rcode === 404 ) { - $status->value = []; // no container - } else { - $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc ); - } - - return $status; - } - - protected function doPrimeContainerCache( array $containerInfo ) { - foreach ( $containerInfo as $container => $info ) { - $this->containerStatCache->set( $container, 'stat', $info ); - } - } - - protected function doGetFileStatMulti( array $params ) { - $stats = []; - - $auth = $this->getAuthentication(); - - $reqs = []; - foreach ( $params['srcs'] as $path ) { - list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $path ); - if ( $srcRel === null ) { - $stats[$path] = false; - continue; // invalid storage path - } elseif ( !$auth ) { - $stats[$path] = null; - continue; - } - - // (a) Check the container - $cstat = $this->getContainerStat( $srcCont ); - if ( $cstat === false ) { - $stats[$path] = false; - continue; // ok, nothing to do - } elseif ( !is_array( $cstat ) ) { - $stats[$path] = null; - continue; - } - - $reqs[$path] = [ - 'method' => 'HEAD', - 'url' => $this->storageUrl( $auth, $srcCont, $srcRel ), - 'headers' => $this->authTokenHeaders( $auth ) + $this->headersFromParams( $params ) - ]; - } - - $opts = [ 'maxConnsPerHost' => $params['concurrency'] ]; - $reqs = $this->http->runMulti( $reqs, $opts ); - - foreach ( $params['srcs'] as $path ) { - if ( array_key_exists( $path, $stats ) ) { - continue; // some sort of failure above - } - // (b) Check the file - list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $reqs[$path]['response']; - if ( $rcode === 200 || $rcode === 204 ) { - // Update the object if it is missing some headers - $rhdrs = $this->addMissingMetadata( $rhdrs, $path ); - // Load the stat array from the headers - $stat = $this->getStatFromHeaders( $rhdrs ); - if ( $this->isRGW ) { - $stat['latest'] = true; // strong consistency - } - } elseif ( $rcode === 404 ) { - $stat = false; - } else { - $stat = null; - $this->onError( null, __METHOD__, $params, $rerr, $rcode, $rdesc ); - } - $stats[$path] = $stat; - } - - return $stats; - } - - /** - * @param array $rhdrs - * @return array - */ - protected function getStatFromHeaders( array $rhdrs ) { - // Fetch all of the custom metadata headers - $metadata = $this->getMetadata( $rhdrs ); - // Fetch all of the custom raw HTTP headers - $headers = $this->sanitizeHdrs( [ 'headers' => $rhdrs ] ); - - return [ - // Convert various random Swift dates to TS_MW - 'mtime' => $this->convertSwiftDate( $rhdrs['last-modified'], TS_MW ), - // Empty objects actually return no content-length header in Ceph - 'size' => isset( $rhdrs['content-length'] ) ? (int)$rhdrs['content-length'] : 0, - 'sha1' => isset( $metadata['sha1base36'] ) ? $metadata['sha1base36'] : null, - // Note: manifiest ETags are not an MD5 of the file - 'md5' => ctype_xdigit( $rhdrs['etag'] ) ? $rhdrs['etag'] : null, - 'xattr' => [ 'metadata' => $metadata, 'headers' => $headers ] - ]; - } - - /** - * @return array|null Credential map - */ - protected function getAuthentication() { - if ( $this->authErrorTimestamp !== null ) { - if ( ( time() - $this->authErrorTimestamp ) < 60 ) { - return null; // failed last attempt; don't bother - } else { // actually retry this time - $this->authErrorTimestamp = null; - } - } - // Session keys expire after a while, so we renew them periodically - $reAuth = ( ( time() - $this->authSessionTimestamp ) > $this->authTTL ); - // Authenticate with proxy and get a session key... - if ( !$this->authCreds || $reAuth ) { - $this->authSessionTimestamp = 0; - $cacheKey = $this->getCredsCacheKey( $this->swiftUser ); - $creds = $this->srvCache->get( $cacheKey ); // credentials - // Try to use the credential cache - if ( isset( $creds['auth_token'] ) && isset( $creds['storage_url'] ) ) { - $this->authCreds = $creds; - // Skew the timestamp for worst case to avoid using stale credentials - $this->authSessionTimestamp = time() - ceil( $this->authTTL / 2 ); - } else { // cache miss - list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [ - 'method' => 'GET', - 'url' => "{$this->swiftAuthUrl}/v1.0", - 'headers' => [ - 'x-auth-user' => $this->swiftUser, - 'x-auth-key' => $this->swiftKey - ] - ] ); - - if ( $rcode >= 200 && $rcode <= 299 ) { // OK - $this->authCreds = [ - 'auth_token' => $rhdrs['x-auth-token'], - 'storage_url' => $rhdrs['x-storage-url'] - ]; - $this->srvCache->set( $cacheKey, $this->authCreds, ceil( $this->authTTL / 2 ) ); - $this->authSessionTimestamp = time(); - } elseif ( $rcode === 401 ) { - $this->onError( null, __METHOD__, [], "Authentication failed.", $rcode ); - $this->authErrorTimestamp = time(); - - return null; - } else { - $this->onError( null, __METHOD__, [], "HTTP return code: $rcode", $rcode ); - $this->authErrorTimestamp = time(); - - return null; - } - } - // Ceph RGW does not use in URLs (OpenStack Swift uses "/v1/") - if ( substr( $this->authCreds['storage_url'], -3 ) === '/v1' ) { - $this->isRGW = true; // take advantage of strong consistency in Ceph - } - } - - return $this->authCreds; - } - - /** - * @param array $creds From getAuthentication() - * @param string $container - * @param string $object - * @return array - */ - protected function storageUrl( array $creds, $container = null, $object = null ) { - $parts = [ $creds['storage_url'] ]; - if ( strlen( $container ) ) { - $parts[] = rawurlencode( $container ); - } - if ( strlen( $object ) ) { - $parts[] = str_replace( "%2F", "/", rawurlencode( $object ) ); - } - - return implode( '/', $parts ); - } - - /** - * @param array $creds From getAuthentication() - * @return array - */ - protected function authTokenHeaders( array $creds ) { - return [ 'x-auth-token' => $creds['auth_token'] ]; - } - - /** - * Get the cache key for a container - * - * @param string $username - * @return string - */ - private function getCredsCacheKey( $username ) { - return 'swiftcredentials:' . md5( $username . ':' . $this->swiftAuthUrl ); - } - - /** - * Log an unexpected exception for this backend. - * This also sets the Status object to have a fatal error. - * - * @param Status|null $status - * @param string $func - * @param array $params - * @param string $err Error string - * @param int $code HTTP status - * @param string $desc HTTP status description - */ - public function onError( $status, $func, array $params, $err = '', $code = 0, $desc = '' ) { - if ( $status instanceof Status ) { - $status->fatal( 'backend-fail-internal', $this->name ); - } - if ( $code == 401 ) { // possibly a stale token - $this->srvCache->delete( $this->getCredsCacheKey( $this->swiftUser ) ); - } - wfDebugLog( 'SwiftBackend', - "HTTP $code ($desc) in '{$func}' (given '" . FormatJson::encode( $params ) . "')" . - ( $err ? ": $err" : "" ) - ); - } -} - -/** - * @see FileBackendStoreOpHandle - */ -class SwiftFileOpHandle extends FileBackendStoreOpHandle { - /** @var array List of Requests for MultiHttpClient */ - public $httpOp; - /** @var Closure */ - public $callback; - - /** - * @param SwiftFileBackend $backend - * @param Closure $callback Function that takes (HTTP request array, status) - * @param array $httpOp MultiHttpClient op - */ - public function __construct( SwiftFileBackend $backend, Closure $callback, array $httpOp ) { - $this->backend = $backend; - $this->callback = $callback; - $this->httpOp = $httpOp; - } -} - -/** - * SwiftFileBackend helper class to page through listings. - * Swift also has a listing limit of 10,000 objects for sanity. - * Do not use this class from places outside SwiftFileBackend. - * - * @ingroup FileBackend - */ -abstract class SwiftFileBackendList implements Iterator { - /** @var array List of path or (path,stat array) entries */ - protected $bufferIter = []; - - /** @var string List items *after* this path */ - protected $bufferAfter = null; - - /** @var int */ - protected $pos = 0; - - /** @var array */ - protected $params = []; - - /** @var SwiftFileBackend */ - protected $backend; - - /** @var string Container name */ - protected $container; - - /** @var string Storage directory */ - protected $dir; - - /** @var int */ - protected $suffixStart; - - const PAGE_SIZE = 9000; // file listing buffer size - - /** - * @param SwiftFileBackend $backend - * @param string $fullCont Resolved container name - * @param string $dir Resolved directory relative to container - * @param array $params - */ - public function __construct( SwiftFileBackend $backend, $fullCont, $dir, array $params ) { - $this->backend = $backend; - $this->container = $fullCont; - $this->dir = $dir; - if ( substr( $this->dir, -1 ) === '/' ) { - $this->dir = substr( $this->dir, 0, -1 ); // remove trailing slash - } - if ( $this->dir == '' ) { // whole container - $this->suffixStart = 0; - } else { // dir within container - $this->suffixStart = strlen( $this->dir ) + 1; // size of "path/to/dir/" - } - $this->params = $params; - } - - /** - * @see Iterator::key() - * @return int - */ - public function key() { - return $this->pos; - } - - /** - * @see Iterator::next() - */ - public function next() { - // Advance to the next file in the page - next( $this->bufferIter ); - ++$this->pos; - // Check if there are no files left in this page and - // advance to the next page if this page was not empty. - if ( !$this->valid() && count( $this->bufferIter ) ) { - $this->bufferIter = $this->pageFromList( - $this->container, $this->dir, $this->bufferAfter, self::PAGE_SIZE, $this->params - ); // updates $this->bufferAfter - } - } - - /** - * @see Iterator::rewind() - */ - public function rewind() { - $this->pos = 0; - $this->bufferAfter = null; - $this->bufferIter = $this->pageFromList( - $this->container, $this->dir, $this->bufferAfter, self::PAGE_SIZE, $this->params - ); // updates $this->bufferAfter - } - - /** - * @see Iterator::valid() - * @return bool - */ - public function valid() { - if ( $this->bufferIter === null ) { - return false; // some failure? - } else { - return ( current( $this->bufferIter ) !== false ); // no paths can have this value - } - } - - /** - * Get the given list portion (page) - * - * @param string $container Resolved container name - * @param string $dir Resolved path relative to container - * @param string $after - * @param int $limit - * @param array $params - * @return Traversable|array - */ - abstract protected function pageFromList( $container, $dir, &$after, $limit, array $params ); -} - -/** - * Iterator for listing directories - */ -class SwiftFileBackendDirList extends SwiftFileBackendList { - /** - * @see Iterator::current() - * @return string|bool String (relative path) or false - */ - public function current() { - return substr( current( $this->bufferIter ), $this->suffixStart, -1 ); - } - - protected function pageFromList( $container, $dir, &$after, $limit, array $params ) { - return $this->backend->getDirListPageInternal( $container, $dir, $after, $limit, $params ); - } -} - -/** - * Iterator for listing regular files - */ -class SwiftFileBackendFileList extends SwiftFileBackendList { - /** - * @see Iterator::current() - * @return string|bool String (relative path) or false - */ - public function current() { - list( $path, $stat ) = current( $this->bufferIter ); - $relPath = substr( $path, $this->suffixStart ); - if ( is_array( $stat ) ) { - $storageDir = rtrim( $this->params['dir'], '/' ); - $this->backend->loadListingStatInternal( "$storageDir/$relPath", $stat ); - } - - return $relPath; - } - - protected function pageFromList( $container, $dir, &$after, $limit, array $params ) { - return $this->backend->getFileListPageInternal( $container, $dir, $after, $limit, $params ); - } -} diff --git a/includes/filebackend/TempFSFile.php b/includes/filebackend/TempFSFile.php deleted file mode 100644 index f57284080d..0000000000 --- a/includes/filebackend/TempFSFile.php +++ /dev/null @@ -1,157 +0,0 @@ - 1) for paths to delete on shutdown */ - protected static $pathsCollect = null; - - public function __construct( $path ) { - parent::__construct( $path ); - - if ( self::$pathsCollect === null ) { - self::$pathsCollect = []; - register_shutdown_function( [ __CLASS__, 'purgeAllOnShutdown' ] ); - } - } - - /** - * Make a new temporary file on the file system. - * Temporary files may be purged when the file object falls out of scope. - * - * @param string $prefix - * @param string $extension - * @return TempFSFile|null - */ - public static function factory( $prefix, $extension = '' ) { - $ext = ( $extension != '' ) ? ".{$extension}" : ''; - - $attempts = 5; - while ( $attempts-- ) { - $path = wfTempDir() . '/' . $prefix . wfRandomString( 12 ) . $ext; - MediaWiki\suppressWarnings(); - $newFileHandle = fopen( $path, 'x' ); - MediaWiki\restoreWarnings(); - if ( $newFileHandle ) { - fclose( $newFileHandle ); - $tmpFile = new self( $path ); - $tmpFile->autocollect(); - // Safely instantiated, end loop. - return $tmpFile; - } - } - - // Give up - return null; - } - - /** - * Purge this file off the file system - * - * @return bool Success - */ - public function purge() { - $this->canDelete = false; // done - MediaWiki\suppressWarnings(); - $ok = unlink( $this->path ); - MediaWiki\restoreWarnings(); - - unset( self::$pathsCollect[$this->path] ); - - return $ok; - } - - /** - * Clean up the temporary file only after an object goes out of scope - * - * @param object $object - * @return TempFSFile This object - */ - public function bind( $object ) { - if ( is_object( $object ) ) { - if ( !isset( $object->tempFSFileReferences ) ) { - // Init first since $object might use __get() and return only a copy variable - $object->tempFSFileReferences = []; - } - $object->tempFSFileReferences[] = $this; - } - - return $this; - } - - /** - * Set flag to not clean up after the temporary file - * - * @return TempFSFile This object - */ - public function preserve() { - $this->canDelete = false; - - unset( self::$pathsCollect[$this->path] ); - - return $this; - } - - /** - * Set flag clean up after the temporary file - * - * @return TempFSFile This object - */ - public function autocollect() { - $this->canDelete = true; - - self::$pathsCollect[$this->path] = 1; - - return $this; - } - - /** - * Try to make sure that all files are purged on error - * - * This method should only be called internally - */ - public static function purgeAllOnShutdown() { - foreach ( self::$pathsCollect as $path ) { - MediaWiki\suppressWarnings(); - unlink( $path ); - MediaWiki\restoreWarnings(); - } - } - - /** - * Cleans up after the temporary file by deleting it - */ - function __destruct() { - if ( $this->canDelete ) { - $this->purge(); - } - } -} diff --git a/includes/filebackend/filejournal/DBFileJournal.php b/includes/filebackend/filejournal/DBFileJournal.php index 7efb3a15c1..2e06c40f72 100644 --- a/includes/filebackend/filejournal/DBFileJournal.php +++ b/includes/filebackend/filejournal/DBFileJournal.php @@ -48,10 +48,10 @@ class DBFileJournal extends FileJournal { * @see FileJournal::logChangeBatch() * @param array $entries * @param string $batchId - * @return Status + * @return StatusValue */ protected function doLogChangeBatch( array $entries, $batchId ) { - $status = Status::newGood(); + $status = StatusValue::newGood(); try { $dbw = $this->getMasterDB(); @@ -151,11 +151,11 @@ class DBFileJournal extends FileJournal { /** * @see FileJournal::purgeOldLogs() - * @return Status + * @return StatusValue * @throws DBError */ protected function doPurgeOldLogs() { - $status = Status::newGood(); + $status = StatusValue::newGood(); if ( $this->ttlDays <= 0 ) { return $status; // nothing to do } diff --git a/includes/filebackend/filejournal/FileJournal.php b/includes/filebackend/filejournal/FileJournal.php deleted file mode 100644 index b84e195989..0000000000 --- a/includes/filebackend/filejournal/FileJournal.php +++ /dev/null @@ -1,251 +0,0 @@ -ttlDays = isset( $config['ttlDays'] ) ? $config['ttlDays'] : false; - } - - /** - * Create an appropriate FileJournal object from config - * - * @param array $config - * @param string $backend A registered file backend name - * @throws Exception - * @return FileJournal - */ - final public static function factory( array $config, $backend ) { - $class = $config['class']; - $jrn = new $class( $config ); - if ( !$jrn instanceof self ) { - throw new Exception( "Class given is not an instance of FileJournal." ); - } - $jrn->backend = $backend; - - return $jrn; - } - - /** - * Get a statistically unique ID string - * - * @return string <9 char TS_MW timestamp in base 36><22 random base 36 chars> - */ - final public function getTimestampedUUID() { - $s = ''; - for ( $i = 0; $i < 5; $i++ ) { - $s .= mt_rand( 0, 2147483647 ); - } - $s = Wikimedia\base_convert( sha1( $s ), 16, 36, 31 ); - - return substr( Wikimedia\base_convert( wfTimestamp( TS_MW ), 10, 36, 9 ) . $s, 0, 31 ); - } - - /** - * Log changes made by a batch file operation. - * - * @param array $entries List of file operations (each an array of parameters) which contain: - * op : Basic operation name (create, update, delete) - * path : The storage path of the file - * newSha1 : The final base 36 SHA-1 of the file - * Note that 'false' should be used as the SHA-1 for non-existing files. - * @param string $batchId UUID string that identifies the operation batch - * @return Status - */ - final public function logChangeBatch( array $entries, $batchId ) { - if ( !count( $entries ) ) { - return Status::newGood(); - } - - return $this->doLogChangeBatch( $entries, $batchId ); - } - - /** - * @see FileJournal::logChangeBatch() - * - * @param array $entries List of file operations (each an array of parameters) - * @param string $batchId UUID string that identifies the operation batch - * @return Status - */ - abstract protected function doLogChangeBatch( array $entries, $batchId ); - - /** - * Get the position ID of the latest journal entry - * - * @return int|bool - */ - final public function getCurrentPosition() { - return $this->doGetCurrentPosition(); - } - - /** - * @see FileJournal::getCurrentPosition() - * @return int|bool - */ - abstract protected function doGetCurrentPosition(); - - /** - * Get the position ID of the latest journal entry at some point in time - * - * @param int|string $time Timestamp - * @return int|bool - */ - final public function getPositionAtTime( $time ) { - return $this->doGetPositionAtTime( $time ); - } - - /** - * @see FileJournal::getPositionAtTime() - * @param int|string $time Timestamp - * @return int|bool - */ - abstract protected function doGetPositionAtTime( $time ); - - /** - * Get an array of file change log entries. - * A starting change ID and/or limit can be specified. - * - * @param int $start Starting change ID or null - * @param int $limit Maximum number of items to return - * @param string &$next Updated to the ID of the next entry. - * @return array List of associative arrays, each having: - * id : unique, monotonic, ID for this change - * batch_uuid : UUID for an operation batch - * backend : the backend name - * op : primitive operation (create,update,delete,null) - * path : affected storage path - * new_sha1 : base 36 sha1 of the new file had the operation succeeded - * timestamp : TS_MW timestamp of the batch change - * Also, $next is updated to the ID of the next entry. - */ - final public function getChangeEntries( $start = null, $limit = 0, &$next = null ) { - $entries = $this->doGetChangeEntries( $start, $limit ? $limit + 1 : 0 ); - if ( $limit && count( $entries ) > $limit ) { - $last = array_pop( $entries ); // remove the extra entry - $next = $last['id']; // update for next call - } else { - $next = null; // end of list - } - - return $entries; - } - - /** - * @see FileJournal::getChangeEntries() - * @param int $start - * @param int $limit - * @return array - */ - abstract protected function doGetChangeEntries( $start, $limit ); - - /** - * Purge any old log entries - * - * @return Status - */ - final public function purgeOldLogs() { - return $this->doPurgeOldLogs(); - } - - /** - * @see FileJournal::purgeOldLogs() - * @return Status - */ - abstract protected function doPurgeOldLogs(); -} - -/** - * Simple version of FileJournal that does nothing - * @since 1.20 - */ -class NullFileJournal extends FileJournal { - /** - * @see FileJournal::doLogChangeBatch() - * @param array $entries - * @param string $batchId - * @return Status - */ - protected function doLogChangeBatch( array $entries, $batchId ) { - return Status::newGood(); - } - - /** - * @see FileJournal::doGetCurrentPosition() - * @return int|bool - */ - protected function doGetCurrentPosition() { - return false; - } - - /** - * @see FileJournal::doGetPositionAtTime() - * @param int|string $time Timestamp - * @return int|bool - */ - protected function doGetPositionAtTime( $time ) { - return false; - } - - /** - * @see FileJournal::doGetChangeEntries() - * @param int $start - * @param int $limit - * @return array - */ - protected function doGetChangeEntries( $start, $limit ) { - return []; - } - - /** - * @see FileJournal::doPurgeOldLogs() - * @return Status - */ - protected function doPurgeOldLogs() { - return Status::newGood(); - } -} diff --git a/includes/filebackend/lockmanager/DBLockManager.php b/includes/filebackend/lockmanager/DBLockManager.php deleted file mode 100644 index f4410cad6c..0000000000 --- a/includes/filebackend/lockmanager/DBLockManager.php +++ /dev/null @@ -1,433 +0,0 @@ - server config array) - /** @var BagOStuff */ - protected $statusCache; - - protected $lockExpiry; // integer number of seconds - protected $safeDelay; // integer number of seconds - - protected $session = 0; // random integer - /** @var array Map Database connections (DB name => Database) */ - protected $conns = []; - - /** - * Construct a new instance from configuration. - * - * @param array $config Parameters include: - * - dbServers : Associative array of DB names to server configuration. - * Configuration is an associative array that includes: - * - host : DB server name - * - dbname : DB name - * - type : DB type (mysql,postgres,...) - * - user : DB user - * - password : DB user password - * - tablePrefix : DB table prefix - * - flags : DB flags (see DatabaseBase) - * - dbsByBucket : Array of 1-16 consecutive integer keys, starting from 0, - * each having an odd-numbered list of DB names (peers) as values. - * Any DB named 'localDBMaster' will automatically use the DB master - * settings for this wiki (without the need for a dbServers entry). - * Only use 'localDBMaster' if the domain is a valid wiki ID. - * - lockExpiry : Lock timeout (seconds) for dropped connections. [optional] - * This tells the DB server how long to wait before assuming - * connection failure and releasing all the locks for a session. - */ - public function __construct( array $config ) { - parent::__construct( $config ); - - $this->dbServers = isset( $config['dbServers'] ) - ? $config['dbServers'] - : []; // likely just using 'localDBMaster' - // Sanitize srvsByBucket config to prevent PHP errors - $this->srvsByBucket = array_filter( $config['dbsByBucket'], 'is_array' ); - $this->srvsByBucket = array_values( $this->srvsByBucket ); // consecutive - - if ( isset( $config['lockExpiry'] ) ) { - $this->lockExpiry = $config['lockExpiry']; - } else { - $met = ini_get( 'max_execution_time' ); - $this->lockExpiry = $met ? $met : 60; // use some sane amount if 0 - } - $this->safeDelay = ( $this->lockExpiry <= 0 ) - ? 60 // pick a safe-ish number to match DB timeout default - : $this->lockExpiry; // cover worst case - - foreach ( $this->srvsByBucket as $bucket ) { - if ( count( $bucket ) > 1 ) { // multiple peers - // Tracks peers that couldn't be queried recently to avoid lengthy - // connection timeouts. This is useless if each bucket has one peer. - $this->statusCache = ObjectCache::getLocalServerInstance(); - break; - } - } - - $this->session = wfRandomString( 31 ); - } - - // @todo change this code to work in one batch - protected function getLocksOnServer( $lockSrv, array $pathsByType ) { - $status = Status::newGood(); - foreach ( $pathsByType as $type => $paths ) { - $status->merge( $this->doGetLocksOnServer( $lockSrv, $paths, $type ) ); - } - - return $status; - } - - protected function freeLocksOnServer( $lockSrv, array $pathsByType ) { - return Status::newGood(); - } - - /** - * @see QuorumLockManager::isServerUp() - * @param string $lockSrv - * @return bool - */ - protected function isServerUp( $lockSrv ) { - if ( !$this->cacheCheckFailures( $lockSrv ) ) { - return false; // recent failure to connect - } - try { - $this->getConnection( $lockSrv ); - } catch ( DBError $e ) { - $this->cacheRecordFailure( $lockSrv ); - - return false; // failed to connect - } - - return true; - } - - /** - * Get (or reuse) a connection to a lock DB - * - * @param string $lockDb - * @return IDatabase - * @throws DBError - */ - protected function getConnection( $lockDb ) { - if ( !isset( $this->conns[$lockDb] ) ) { - $db = null; - if ( $lockDb === 'localDBMaster' ) { - $lb = wfGetLBFactory()->getMainLB( $this->domain ); - $db = $lb->getConnection( DB_MASTER, [], $this->domain ); - } elseif ( isset( $this->dbServers[$lockDb] ) ) { - $config = $this->dbServers[$lockDb]; - $db = DatabaseBase::factory( $config['type'], $config ); - } - if ( !$db ) { - return null; // config error? - } - $this->conns[$lockDb] = $db; - $this->conns[$lockDb]->clearFlag( DBO_TRX ); - # If the connection drops, try to avoid letting the DB rollback - # and release the locks before the file operations are finished. - # This won't handle the case of DB server restarts however. - $options = []; - if ( $this->lockExpiry > 0 ) { - $options['connTimeout'] = $this->lockExpiry; - } - $this->conns[$lockDb]->setSessionOptions( $options ); - $this->initConnection( $lockDb, $this->conns[$lockDb] ); - } - if ( !$this->conns[$lockDb]->trxLevel() ) { - $this->conns[$lockDb]->begin( __METHOD__ ); // start transaction - } - - return $this->conns[$lockDb]; - } - - /** - * Do additional initialization for new lock DB connection - * - * @param string $lockDb - * @param IDatabase $db - * @throws DBError - */ - protected function initConnection( $lockDb, IDatabase $db ) { - } - - /** - * Checks if the DB has not recently had connection/query errors. - * This just avoids wasting time on doomed connection attempts. - * - * @param string $lockDb - * @return bool - */ - protected function cacheCheckFailures( $lockDb ) { - return ( $this->statusCache && $this->safeDelay > 0 ) - ? !$this->statusCache->get( $this->getMissKey( $lockDb ) ) - : true; - } - - /** - * Log a lock request failure to the cache - * - * @param string $lockDb - * @return bool Success - */ - protected function cacheRecordFailure( $lockDb ) { - return ( $this->statusCache && $this->safeDelay > 0 ) - ? $this->statusCache->set( $this->getMissKey( $lockDb ), 1, $this->safeDelay ) - : true; - } - - /** - * Get a cache key for recent query misses for a DB - * - * @param string $lockDb - * @return string - */ - protected function getMissKey( $lockDb ) { - $lockDb = ( $lockDb === 'localDBMaster' ) ? wfWikiID() : $lockDb; // non-relative - return 'dblockmanager:downservers:' . str_replace( ' ', '_', $lockDb ); - } - - /** - * Make sure remaining locks get cleared for sanity - */ - function __destruct() { - $this->releaseAllLocks(); - foreach ( $this->conns as $db ) { - $db->close(); - } - } -} - -/** - * MySQL version of DBLockManager that supports shared locks. - * All locks are non-blocking, which avoids deadlocks. - * - * @ingroup LockManager - */ -class MySqlLockManager extends DBLockManager { - /** @var array Mapping of lock types to the type actually used */ - protected $lockTypeMap = [ - self::LOCK_SH => self::LOCK_SH, - self::LOCK_UW => self::LOCK_SH, - self::LOCK_EX => self::LOCK_EX - ]; - - /** - * @param string $lockDb - * @param IDatabase $db - */ - protected function initConnection( $lockDb, IDatabase $db ) { - # Let this transaction see lock rows from other transactions - $db->query( "SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;" ); - } - - /** - * Get a connection to a lock DB and acquire locks on $paths. - * This does not use GET_LOCK() per http://bugs.mysql.com/bug.php?id=1118. - * - * @see DBLockManager::getLocksOnServer() - * @param string $lockSrv - * @param array $paths - * @param string $type - * @return Status - */ - protected function doGetLocksOnServer( $lockSrv, array $paths, $type ) { - $status = Status::newGood(); - - $db = $this->getConnection( $lockSrv ); // checked in isServerUp() - - $keys = []; // list of hash keys for the paths - $data = []; // list of rows to insert - $checkEXKeys = []; // list of hash keys that this has no EX lock on - # Build up values for INSERT clause - foreach ( $paths as $path ) { - $key = $this->sha1Base36Absolute( $path ); - $keys[] = $key; - $data[] = [ 'fls_key' => $key, 'fls_session' => $this->session ]; - if ( !isset( $this->locksHeld[$path][self::LOCK_EX] ) ) { - $checkEXKeys[] = $key; - } - } - - # Block new writers (both EX and SH locks leave entries here)... - $db->insert( 'filelocks_shared', $data, __METHOD__, [ 'IGNORE' ] ); - # Actually do the locking queries... - if ( $type == self::LOCK_SH ) { // reader locks - $blocked = false; - # Bail if there are any existing writers... - if ( count( $checkEXKeys ) ) { - $blocked = $db->selectField( 'filelocks_exclusive', '1', - [ 'fle_key' => $checkEXKeys ], - __METHOD__ - ); - } - # Other prospective writers that haven't yet updated filelocks_exclusive - # will recheck filelocks_shared after doing so and bail due to this entry. - } else { // writer locks - $encSession = $db->addQuotes( $this->session ); - # Bail if there are any existing writers... - # This may detect readers, but the safe check for them is below. - # Note: if two writers come at the same time, both bail :) - $blocked = $db->selectField( 'filelocks_shared', '1', - [ 'fls_key' => $keys, "fls_session != $encSession" ], - __METHOD__ - ); - if ( !$blocked ) { - # Build up values for INSERT clause - $data = []; - foreach ( $keys as $key ) { - $data[] = [ 'fle_key' => $key ]; - } - # Block new readers/writers... - $db->insert( 'filelocks_exclusive', $data, __METHOD__ ); - # Bail if there are any existing readers... - $blocked = $db->selectField( 'filelocks_shared', '1', - [ 'fls_key' => $keys, "fls_session != $encSession" ], - __METHOD__ - ); - } - } - - if ( $blocked ) { - foreach ( $paths as $path ) { - $status->fatal( 'lockmanager-fail-acquirelock', $path ); - } - } - - return $status; - } - - /** - * @see QuorumLockManager::releaseAllLocks() - * @return Status - */ - protected function releaseAllLocks() { - $status = Status::newGood(); - - foreach ( $this->conns as $lockDb => $db ) { - if ( $db->trxLevel() ) { // in transaction - try { - $db->rollback( __METHOD__ ); // finish transaction and kill any rows - } catch ( DBError $e ) { - $status->fatal( 'lockmanager-fail-db-release', $lockDb ); - } - } - } - - return $status; - } -} - -/** - * PostgreSQL version of DBLockManager that supports shared locks. - * All locks are non-blocking, which avoids deadlocks. - * - * @ingroup LockManager - */ -class PostgreSqlLockManager extends DBLockManager { - /** @var array Mapping of lock types to the type actually used */ - protected $lockTypeMap = [ - self::LOCK_SH => self::LOCK_SH, - self::LOCK_UW => self::LOCK_SH, - self::LOCK_EX => self::LOCK_EX - ]; - - protected function doGetLocksOnServer( $lockSrv, array $paths, $type ) { - $status = Status::newGood(); - if ( !count( $paths ) ) { - return $status; // nothing to lock - } - - $db = $this->getConnection( $lockSrv ); // checked in isServerUp() - $bigints = array_unique( array_map( - function ( $key ) { - return Wikimedia\base_convert( substr( $key, 0, 15 ), 16, 10 ); - }, - array_map( [ $this, 'sha1Base16Absolute' ], $paths ) - ) ); - - // Try to acquire all the locks... - $fields = []; - foreach ( $bigints as $bigint ) { - $fields[] = ( $type == self::LOCK_SH ) - ? "pg_try_advisory_lock_shared({$db->addQuotes( $bigint )}) AS K$bigint" - : "pg_try_advisory_lock({$db->addQuotes( $bigint )}) AS K$bigint"; - } - $res = $db->query( 'SELECT ' . implode( ', ', $fields ), __METHOD__ ); - $row = $res->fetchRow(); - - if ( in_array( 'f', $row ) ) { - // Release any acquired locks if some could not be acquired... - $fields = []; - foreach ( $row as $kbigint => $ok ) { - if ( $ok === 't' ) { // locked - $bigint = substr( $kbigint, 1 ); // strip off the "K" - $fields[] = ( $type == self::LOCK_SH ) - ? "pg_advisory_unlock_shared({$db->addQuotes( $bigint )})" - : "pg_advisory_unlock({$db->addQuotes( $bigint )})"; - } - } - if ( count( $fields ) ) { - $db->query( 'SELECT ' . implode( ', ', $fields ), __METHOD__ ); - } - foreach ( $paths as $path ) { - $status->fatal( 'lockmanager-fail-acquirelock', $path ); - } - } - - return $status; - } - - /** - * @see QuorumLockManager::releaseAllLocks() - * @return Status - */ - protected function releaseAllLocks() { - $status = Status::newGood(); - - foreach ( $this->conns as $lockDb => $db ) { - try { - $db->query( "SELECT pg_advisory_unlock_all()", __METHOD__ ); - } catch ( DBError $e ) { - $status->fatal( 'lockmanager-fail-db-release', $lockDb ); - } - } - - return $status; - } -} diff --git a/includes/filebackend/lockmanager/FSLockManager.php b/includes/filebackend/lockmanager/FSLockManager.php deleted file mode 100644 index 2b660ec77e..0000000000 --- a/includes/filebackend/lockmanager/FSLockManager.php +++ /dev/null @@ -1,248 +0,0 @@ - self::LOCK_SH, - self::LOCK_UW => self::LOCK_SH, - self::LOCK_EX => self::LOCK_EX - ]; - - protected $lockDir; // global dir for all servers - - /** @var array Map of (locked key => lock file handle) */ - protected $handles = []; - - /** - * Construct a new instance from configuration. - * - * @param array $config Includes: - * - lockDirectory : Directory containing the lock files - */ - function __construct( array $config ) { - parent::__construct( $config ); - - $this->lockDir = $config['lockDirectory']; - } - - /** - * @see LockManager::doLock() - * @param array $paths - * @param int $type - * @return Status - */ - protected function doLock( array $paths, $type ) { - $status = Status::newGood(); - - $lockedPaths = []; // files locked in this attempt - foreach ( $paths as $path ) { - $status->merge( $this->doSingleLock( $path, $type ) ); - if ( $status->isOK() ) { - $lockedPaths[] = $path; - } else { - // Abort and unlock everything - $status->merge( $this->doUnlock( $lockedPaths, $type ) ); - - return $status; - } - } - - return $status; - } - - /** - * @see LockManager::doUnlock() - * @param array $paths - * @param int $type - * @return Status - */ - protected function doUnlock( array $paths, $type ) { - $status = Status::newGood(); - - foreach ( $paths as $path ) { - $status->merge( $this->doSingleUnlock( $path, $type ) ); - } - - return $status; - } - - /** - * Lock a single resource key - * - * @param string $path - * @param int $type - * @return Status - */ - protected function doSingleLock( $path, $type ) { - $status = Status::newGood(); - - if ( isset( $this->locksHeld[$path][$type] ) ) { - ++$this->locksHeld[$path][$type]; - } elseif ( isset( $this->locksHeld[$path][self::LOCK_EX] ) ) { - $this->locksHeld[$path][$type] = 1; - } else { - if ( isset( $this->handles[$path] ) ) { - $handle = $this->handles[$path]; - } else { - MediaWiki\suppressWarnings(); - $handle = fopen( $this->getLockPath( $path ), 'a+' ); - MediaWiki\restoreWarnings(); - if ( !$handle ) { // lock dir missing? - wfMkdirParents( $this->lockDir ); - $handle = fopen( $this->getLockPath( $path ), 'a+' ); // try again - } - } - if ( $handle ) { - // Either a shared or exclusive lock - $lock = ( $type == self::LOCK_SH ) ? LOCK_SH : LOCK_EX; - if ( flock( $handle, $lock | LOCK_NB ) ) { - // Record this lock as active - $this->locksHeld[$path][$type] = 1; - $this->handles[$path] = $handle; - } else { - fclose( $handle ); - $status->fatal( 'lockmanager-fail-acquirelock', $path ); - } - } else { - $status->fatal( 'lockmanager-fail-openlock', $path ); - } - } - - return $status; - } - - /** - * Unlock a single resource key - * - * @param string $path - * @param int $type - * @return Status - */ - protected function doSingleUnlock( $path, $type ) { - $status = Status::newGood(); - - if ( !isset( $this->locksHeld[$path] ) ) { - $status->warning( 'lockmanager-notlocked', $path ); - } elseif ( !isset( $this->locksHeld[$path][$type] ) ) { - $status->warning( 'lockmanager-notlocked', $path ); - } else { - $handlesToClose = []; - --$this->locksHeld[$path][$type]; - if ( $this->locksHeld[$path][$type] <= 0 ) { - unset( $this->locksHeld[$path][$type] ); - } - if ( !count( $this->locksHeld[$path] ) ) { - unset( $this->locksHeld[$path] ); // no locks on this path - if ( isset( $this->handles[$path] ) ) { - $handlesToClose[] = $this->handles[$path]; - unset( $this->handles[$path] ); - } - } - // Unlock handles to release locks and delete - // any lock files that end up with no locks on them... - if ( wfIsWindows() ) { - // Windows: for any process, including this one, - // calling unlink() on a locked file will fail - $status->merge( $this->closeLockHandles( $path, $handlesToClose ) ); - $status->merge( $this->pruneKeyLockFiles( $path ) ); - } else { - // Unix: unlink() can be used on files currently open by this - // process and we must do so in order to avoid race conditions - $status->merge( $this->pruneKeyLockFiles( $path ) ); - $status->merge( $this->closeLockHandles( $path, $handlesToClose ) ); - } - } - - return $status; - } - - /** - * @param string $path - * @param array $handlesToClose - * @return Status - */ - private function closeLockHandles( $path, array $handlesToClose ) { - $status = Status::newGood(); - foreach ( $handlesToClose as $handle ) { - if ( !flock( $handle, LOCK_UN ) ) { - $status->fatal( 'lockmanager-fail-releaselock', $path ); - } - if ( !fclose( $handle ) ) { - $status->warning( 'lockmanager-fail-closelock', $path ); - } - } - - return $status; - } - - /** - * @param string $path - * @return Status - */ - private function pruneKeyLockFiles( $path ) { - $status = Status::newGood(); - if ( !isset( $this->locksHeld[$path] ) ) { - # No locks are held for the lock file anymore - if ( !unlink( $this->getLockPath( $path ) ) ) { - $status->warning( 'lockmanager-fail-deletelock', $path ); - } - unset( $this->handles[$path] ); - } - - return $status; - } - - /** - * Get the path to the lock file for a key - * @param string $path - * @return string - */ - protected function getLockPath( $path ) { - return "{$this->lockDir}/{$this->sha1Base36Absolute( $path )}.lock"; - } - - /** - * Make sure remaining locks get cleared for sanity - */ - function __destruct() { - while ( count( $this->locksHeld ) ) { - foreach ( $this->locksHeld as $path => $locks ) { - $this->doSingleUnlock( $path, self::LOCK_EX ); - $this->doSingleUnlock( $path, self::LOCK_SH ); - } - } - } -} diff --git a/includes/filebackend/lockmanager/LockManager.php b/includes/filebackend/lockmanager/LockManager.php deleted file mode 100644 index 567a29892e..0000000000 --- a/includes/filebackend/lockmanager/LockManager.php +++ /dev/null @@ -1,258 +0,0 @@ - self::LOCK_SH, - self::LOCK_UW => self::LOCK_EX, // subclasses may use self::LOCK_SH - self::LOCK_EX => self::LOCK_EX - ]; - - /** @var array Map of (resource path => lock type => count) */ - protected $locksHeld = []; - - protected $domain; // string; domain (usually wiki ID) - protected $lockTTL; // integer; maximum time locks can be held - - /** Lock types; stronger locks have higher values */ - const LOCK_SH = 1; // shared lock (for reads) - const LOCK_UW = 2; // shared lock (for reads used to write elsewhere) - const LOCK_EX = 3; // exclusive lock (for writes) - - /** - * Construct a new instance from configuration - * - * @param array $config Parameters include: - * - domain : Domain (usually wiki ID) that all resources are relative to [optional] - * - lockTTL : Age (in seconds) at which resource locks should expire. - * This only applies if locks are not tied to a connection/process. - */ - public function __construct( array $config ) { - $this->domain = isset( $config['domain'] ) ? $config['domain'] : wfWikiID(); - if ( isset( $config['lockTTL'] ) ) { - $this->lockTTL = max( 5, $config['lockTTL'] ); - } elseif ( PHP_SAPI === 'cli' ) { - $this->lockTTL = 3600; - } else { - $met = ini_get( 'max_execution_time' ); // this is 0 in CLI mode - $this->lockTTL = max( 5 * 60, 2 * (int)$met ); - } - } - - /** - * Lock the resources at the given abstract paths - * - * @param array $paths List of resource names - * @param int $type LockManager::LOCK_* constant - * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.21) - * @return Status - */ - final public function lock( array $paths, $type = self::LOCK_EX, $timeout = 0 ) { - return $this->lockByType( [ $type => $paths ], $timeout ); - } - - /** - * Lock the resources at the given abstract paths - * - * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths - * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.21) - * @return Status - * @since 1.22 - */ - final public function lockByType( array $pathsByType, $timeout = 0 ) { - $pathsByType = $this->normalizePathsByType( $pathsByType ); - $msleep = [ 0, 50, 100, 300, 500 ]; // retry backoff times - $start = microtime( true ); - do { - $status = $this->doLockByType( $pathsByType ); - $elapsed = microtime( true ) - $start; - if ( $status->isOK() || $elapsed >= $timeout || $elapsed < 0 ) { - break; // success, timeout, or clock set back - } - usleep( 1e3 * ( next( $msleep ) ?: 1000 ) ); // use 1 sec after enough times - $elapsed = microtime( true ) - $start; - } while ( $elapsed < $timeout && $elapsed >= 0 ); - - return $status; - } - - /** - * Unlock the resources at the given abstract paths - * - * @param array $paths List of paths - * @param int $type LockManager::LOCK_* constant - * @return Status - */ - final public function unlock( array $paths, $type = self::LOCK_EX ) { - return $this->unlockByType( [ $type => $paths ] ); - } - - /** - * Unlock the resources at the given abstract paths - * - * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths - * @return Status - * @since 1.22 - */ - final public function unlockByType( array $pathsByType ) { - $pathsByType = $this->normalizePathsByType( $pathsByType ); - $status = $this->doUnlockByType( $pathsByType ); - - return $status; - } - - /** - * Get the base 36 SHA-1 of a string, padded to 31 digits. - * Before hashing, the path will be prefixed with the domain ID. - * This should be used interally for lock key or file names. - * - * @param string $path - * @return string - */ - final protected function sha1Base36Absolute( $path ) { - return Wikimedia\base_convert( sha1( "{$this->domain}:{$path}" ), 16, 36, 31 ); - } - - /** - * Get the base 16 SHA-1 of a string, padded to 31 digits. - * Before hashing, the path will be prefixed with the domain ID. - * This should be used interally for lock key or file names. - * - * @param string $path - * @return string - */ - final protected function sha1Base16Absolute( $path ) { - return sha1( "{$this->domain}:{$path}" ); - } - - /** - * Normalize the $paths array by converting LOCK_UW locks into the - * appropriate type and removing any duplicated paths for each lock type. - * - * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths - * @return array - * @since 1.22 - */ - final protected function normalizePathsByType( array $pathsByType ) { - $res = []; - foreach ( $pathsByType as $type => $paths ) { - $res[$this->lockTypeMap[$type]] = array_unique( $paths ); - } - - return $res; - } - - /** - * @see LockManager::lockByType() - * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths - * @return Status - * @since 1.22 - */ - protected function doLockByType( array $pathsByType ) { - $status = Status::newGood(); - $lockedByType = []; // map of (type => paths) - foreach ( $pathsByType as $type => $paths ) { - $status->merge( $this->doLock( $paths, $type ) ); - if ( $status->isOK() ) { - $lockedByType[$type] = $paths; - } else { - // Release the subset of locks that were acquired - foreach ( $lockedByType as $lType => $lPaths ) { - $status->merge( $this->doUnlock( $lPaths, $lType ) ); - } - break; - } - } - - return $status; - } - - /** - * Lock resources with the given keys and lock type - * - * @param array $paths List of paths - * @param int $type LockManager::LOCK_* constant - * @return Status - */ - abstract protected function doLock( array $paths, $type ); - - /** - * @see LockManager::unlockByType() - * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths - * @return Status - * @since 1.22 - */ - protected function doUnlockByType( array $pathsByType ) { - $status = Status::newGood(); - foreach ( $pathsByType as $type => $paths ) { - $status->merge( $this->doUnlock( $paths, $type ) ); - } - - return $status; - } - - /** - * Unlock resources with the given keys and lock type - * - * @param array $paths List of paths - * @param int $type LockManager::LOCK_* constant - * @return Status - */ - abstract protected function doUnlock( array $paths, $type ); -} - -/** - * Simple version of LockManager that does nothing - * @since 1.19 - */ -class NullLockManager extends LockManager { - protected function doLock( array $paths, $type ) { - return Status::newGood(); - } - - protected function doUnlock( array $paths, $type ) { - return Status::newGood(); - } -} diff --git a/includes/filebackend/lockmanager/LockManagerGroup.php b/includes/filebackend/lockmanager/LockManagerGroup.php index 602b876b8a..1e66e6e011 100644 --- a/includes/filebackend/lockmanager/LockManagerGroup.php +++ b/includes/filebackend/lockmanager/LockManagerGroup.php @@ -20,6 +20,8 @@ * @file * @ingroup LockManager */ +use MediaWiki\MediaWikiServices; +use MediaWiki\Logger\LoggerFactory; /** * Class to handle file lock manager registration @@ -29,7 +31,7 @@ * @since 1.19 */ class LockManagerGroup { - /** @var array (domain => LockManager) */ + /** @var LockManagerGroup[] (domain => LockManagerGroup) */ protected static $instances = []; protected $domain; // string; domain (usually wiki ID) @@ -115,6 +117,16 @@ class LockManagerGroup { if ( !isset( $this->managers[$name]['instance'] ) ) { $class = $this->managers[$name]['class']; $config = $this->managers[$name]['config']; + if ( $class === 'DBLockManager' ) { + $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); + $lb = $lbFactory->newMainLB( $config['domain'] ); + $dbw = $lb->getLazyConnectionRef( DB_MASTER, [], $config['domain'] ); + + $config['dbServers']['localDBMaster'] = $dbw; + $config['srvCache'] = ObjectCache::getLocalServerInstance( 'hash' ); + } + $config['logger'] = LoggerFactory::getInstance( 'LockManager' ); + $this->managers[$name]['instance'] = new $class( $config ); } diff --git a/includes/filebackend/lockmanager/MemcLockManager.php b/includes/filebackend/lockmanager/MemcLockManager.php deleted file mode 100644 index cb5266acd8..0000000000 --- a/includes/filebackend/lockmanager/MemcLockManager.php +++ /dev/null @@ -1,384 +0,0 @@ - self::LOCK_SH, - self::LOCK_UW => self::LOCK_SH, - self::LOCK_EX => self::LOCK_EX - ]; - - /** @var array Map server names to MemcachedBagOStuff objects */ - protected $bagOStuffs = []; - - /** @var array (server name => bool) */ - protected $serversUp = []; - - /** @var string Random UUID */ - protected $session = ''; - - /** - * Construct a new instance from configuration. - * - * @param array $config Parameters include: - * - lockServers : Associative array of server names to ":" strings. - * - srvsByBucket : Array of 1-16 consecutive integer keys, starting from 0, - * each having an odd-numbered list of server names (peers) as values. - * - memcConfig : Configuration array for ObjectCache::newFromParams. [optional] - * If set, this must use one of the memcached classes. - * @throws Exception - */ - public function __construct( array $config ) { - parent::__construct( $config ); - - // Sanitize srvsByBucket config to prevent PHP errors - $this->srvsByBucket = array_filter( $config['srvsByBucket'], 'is_array' ); - $this->srvsByBucket = array_values( $this->srvsByBucket ); // consecutive - - $memcConfig = isset( $config['memcConfig'] ) - ? $config['memcConfig'] - : [ 'class' => 'MemcachedPhpBagOStuff' ]; - - foreach ( $config['lockServers'] as $name => $address ) { - $params = [ 'servers' => [ $address ] ] + $memcConfig; - $cache = ObjectCache::newFromParams( $params ); - if ( $cache instanceof MemcachedBagOStuff ) { - $this->bagOStuffs[$name] = $cache; - } else { - throw new Exception( - 'Only MemcachedBagOStuff classes are supported by MemcLockManager.' ); - } - } - - $this->session = wfRandomString( 32 ); - } - - // @todo Change this code to work in one batch - protected function getLocksOnServer( $lockSrv, array $pathsByType ) { - $status = Status::newGood(); - - $lockedPaths = []; - foreach ( $pathsByType as $type => $paths ) { - $status->merge( $this->doGetLocksOnServer( $lockSrv, $paths, $type ) ); - if ( $status->isOK() ) { - $lockedPaths[$type] = isset( $lockedPaths[$type] ) - ? array_merge( $lockedPaths[$type], $paths ) - : $paths; - } else { - foreach ( $lockedPaths as $lType => $lPaths ) { - $status->merge( $this->doFreeLocksOnServer( $lockSrv, $lPaths, $lType ) ); - } - break; - } - } - - return $status; - } - - // @todo Change this code to work in one batch - protected function freeLocksOnServer( $lockSrv, array $pathsByType ) { - $status = Status::newGood(); - - foreach ( $pathsByType as $type => $paths ) { - $status->merge( $this->doFreeLocksOnServer( $lockSrv, $paths, $type ) ); - } - - return $status; - } - - /** - * @see QuorumLockManager::getLocksOnServer() - * @param string $lockSrv - * @param array $paths - * @param string $type - * @return Status - */ - protected function doGetLocksOnServer( $lockSrv, array $paths, $type ) { - $status = Status::newGood(); - - $memc = $this->getCache( $lockSrv ); - $keys = array_map( [ $this, 'recordKeyForPath' ], $paths ); // lock records - - // Lock all of the active lock record keys... - if ( !$this->acquireMutexes( $memc, $keys ) ) { - foreach ( $paths as $path ) { - $status->fatal( 'lockmanager-fail-acquirelock', $path ); - } - - return $status; - } - - // Fetch all the existing lock records... - $lockRecords = $memc->getMulti( $keys ); - - $now = time(); - // Check if the requested locks conflict with existing ones... - foreach ( $paths as $path ) { - $locksKey = $this->recordKeyForPath( $path ); - $locksHeld = isset( $lockRecords[$locksKey] ) - ? self::sanitizeLockArray( $lockRecords[$locksKey] ) - : self::newLockArray(); // init - foreach ( $locksHeld[self::LOCK_EX] as $session => $expiry ) { - if ( $expiry < $now ) { // stale? - unset( $locksHeld[self::LOCK_EX][$session] ); - } elseif ( $session !== $this->session ) { - $status->fatal( 'lockmanager-fail-acquirelock', $path ); - } - } - if ( $type === self::LOCK_EX ) { - foreach ( $locksHeld[self::LOCK_SH] as $session => $expiry ) { - if ( $expiry < $now ) { // stale? - unset( $locksHeld[self::LOCK_SH][$session] ); - } elseif ( $session !== $this->session ) { - $status->fatal( 'lockmanager-fail-acquirelock', $path ); - } - } - } - if ( $status->isOK() ) { - // Register the session in the lock record array - $locksHeld[$type][$this->session] = $now + $this->lockTTL; - // We will update this record if none of the other locks conflict - $lockRecords[$locksKey] = $locksHeld; - } - } - - // If there were no lock conflicts, update all the lock records... - if ( $status->isOK() ) { - foreach ( $paths as $path ) { - $locksKey = $this->recordKeyForPath( $path ); - $locksHeld = $lockRecords[$locksKey]; - $ok = $memc->set( $locksKey, $locksHeld, 7 * 86400 ); - if ( !$ok ) { - $status->fatal( 'lockmanager-fail-acquirelock', $path ); - } else { - wfDebug( __METHOD__ . ": acquired lock on key $locksKey.\n" ); - } - } - } - - // Unlock all of the active lock record keys... - $this->releaseMutexes( $memc, $keys ); - - return $status; - } - - /** - * @see QuorumLockManager::freeLocksOnServer() - * @param string $lockSrv - * @param array $paths - * @param string $type - * @return Status - */ - protected function doFreeLocksOnServer( $lockSrv, array $paths, $type ) { - $status = Status::newGood(); - - $memc = $this->getCache( $lockSrv ); - $keys = array_map( [ $this, 'recordKeyForPath' ], $paths ); // lock records - - // Lock all of the active lock record keys... - if ( !$this->acquireMutexes( $memc, $keys ) ) { - foreach ( $paths as $path ) { - $status->fatal( 'lockmanager-fail-releaselock', $path ); - } - - return $status; - } - - // Fetch all the existing lock records... - $lockRecords = $memc->getMulti( $keys ); - - // Remove the requested locks from all records... - foreach ( $paths as $path ) { - $locksKey = $this->recordKeyForPath( $path ); // lock record - if ( !isset( $lockRecords[$locksKey] ) ) { - $status->warning( 'lockmanager-fail-releaselock', $path ); - continue; // nothing to do - } - $locksHeld = self::sanitizeLockArray( $lockRecords[$locksKey] ); - if ( isset( $locksHeld[$type][$this->session] ) ) { - unset( $locksHeld[$type][$this->session] ); // unregister this session - if ( $locksHeld === self::newLockArray() ) { - $ok = $memc->delete( $locksKey ); - } else { - $ok = $memc->set( $locksKey, $locksHeld ); - } - if ( !$ok ) { - $status->fatal( 'lockmanager-fail-releaselock', $path ); - } - } else { - $status->warning( 'lockmanager-fail-releaselock', $path ); - } - wfDebug( __METHOD__ . ": released lock on key $locksKey.\n" ); - } - - // Unlock all of the active lock record keys... - $this->releaseMutexes( $memc, $keys ); - - return $status; - } - - /** - * @see QuorumLockManager::releaseAllLocks() - * @return Status - */ - protected function releaseAllLocks() { - return Status::newGood(); // not supported - } - - /** - * @see QuorumLockManager::isServerUp() - * @param string $lockSrv - * @return bool - */ - protected function isServerUp( $lockSrv ) { - return (bool)$this->getCache( $lockSrv ); - } - - /** - * Get the MemcachedBagOStuff object for a $lockSrv - * - * @param string $lockSrv Server name - * @return MemcachedBagOStuff|null - */ - protected function getCache( $lockSrv ) { - $memc = null; - if ( isset( $this->bagOStuffs[$lockSrv] ) ) { - $memc = $this->bagOStuffs[$lockSrv]; - if ( !isset( $this->serversUp[$lockSrv] ) ) { - $this->serversUp[$lockSrv] = $memc->set( __CLASS__ . ':ping', 1, 1 ); - if ( !$this->serversUp[$lockSrv] ) { - trigger_error( __METHOD__ . ": Could not contact $lockSrv.", E_USER_WARNING ); - } - } - if ( !$this->serversUp[$lockSrv] ) { - return null; // server appears to be down - } - } - - return $memc; - } - - /** - * @param string $path - * @return string - */ - protected function recordKeyForPath( $path ) { - return implode( ':', [ __CLASS__, 'locks', $this->sha1Base36Absolute( $path ) ] ); - } - - /** - * @return array An empty lock structure for a key - */ - protected static function newLockArray() { - return [ self::LOCK_SH => [], self::LOCK_EX => [] ]; - } - - /** - * @param array $a - * @return array An empty lock structure for a key - */ - protected static function sanitizeLockArray( $a ) { - if ( is_array( $a ) && isset( $a[self::LOCK_EX] ) && isset( $a[self::LOCK_SH] ) ) { - return $a; - } else { - trigger_error( __METHOD__ . ": reset invalid lock array.", E_USER_WARNING ); - - return self::newLockArray(); - } - } - - /** - * @param MemcachedBagOStuff $memc - * @param array $keys List of keys to acquire - * @return bool - */ - protected function acquireMutexes( MemcachedBagOStuff $memc, array $keys ) { - $lockedKeys = []; - - // Acquire the keys in lexicographical order, to avoid deadlock problems. - // If P1 is waiting to acquire a key P2 has, P2 can't also be waiting for a key P1 has. - sort( $keys ); - - // Try to quickly loop to acquire the keys, but back off after a few rounds. - // This reduces memcached spam, especially in the rare case where a server acquires - // some lock keys and dies without releasing them. Lock keys expire after a few minutes. - $rounds = 0; - $start = microtime( true ); - do { - if ( ( ++$rounds % 4 ) == 0 ) { - usleep( 1000 * 50 ); // 50 ms - } - foreach ( array_diff( $keys, $lockedKeys ) as $key ) { - if ( $memc->add( "$key:mutex", 1, 180 ) ) { // lock record - $lockedKeys[] = $key; - } else { - continue; // acquire in order - } - } - } while ( count( $lockedKeys ) < count( $keys ) && ( microtime( true ) - $start ) <= 3 ); - - if ( count( $lockedKeys ) != count( $keys ) ) { - $this->releaseMutexes( $memc, $lockedKeys ); // failed; release what was locked - return false; - } - - return true; - } - - /** - * @param MemcachedBagOStuff $memc - * @param array $keys List of acquired keys - */ - protected function releaseMutexes( MemcachedBagOStuff $memc, array $keys ) { - foreach ( $keys as $key ) { - $memc->delete( "$key:mutex" ); - } - } - - /** - * Make sure remaining locks get cleared for sanity - */ - function __destruct() { - while ( count( $this->locksHeld ) ) { - foreach ( $this->locksHeld as $path => $locks ) { - $this->doUnlock( [ $path ], self::LOCK_EX ); - $this->doUnlock( [ $path ], self::LOCK_SH ); - } - } - } -} diff --git a/includes/filebackend/lockmanager/MySqlLockManager.php b/includes/filebackend/lockmanager/MySqlLockManager.php new file mode 100644 index 0000000000..5936e7d1d2 --- /dev/null +++ b/includes/filebackend/lockmanager/MySqlLockManager.php @@ -0,0 +1,137 @@ + self::LOCK_SH, + self::LOCK_UW => self::LOCK_SH, + self::LOCK_EX => self::LOCK_EX + ]; + + public function __construct( array $config ) { + parent::__construct( $config ); + + $this->session = substr( $this->session, 0, 31 ); // fit to field + } + + protected function initConnection( $lockDb, IDatabase $db ) { + # Let this transaction see lock rows from other transactions + $db->query( "SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;" ); + # Do everything in a transaction as it all gets rolled back eventually + $db->startAtomic( __CLASS__ ); + } + + /** + * Get a connection to a lock DB and acquire locks on $paths. + * This does not use GET_LOCK() per https://bugs.mysql.com/bug.php?id=1118. + * + * @see DBLockManager::getLocksOnServer() + * @param string $lockSrv + * @param array $paths + * @param string $type + * @return StatusValue + */ + protected function doGetLocksOnServer( $lockSrv, array $paths, $type ) { + $status = StatusValue::newGood(); + + $db = $this->getConnection( $lockSrv ); // checked in isServerUp() + + $keys = []; // list of hash keys for the paths + $data = []; // list of rows to insert + $checkEXKeys = []; // list of hash keys that this has no EX lock on + # Build up values for INSERT clause + foreach ( $paths as $path ) { + $key = $this->sha1Base36Absolute( $path ); + $keys[] = $key; + $data[] = [ 'fls_key' => $key, 'fls_session' => $this->session ]; + if ( !isset( $this->locksHeld[$path][self::LOCK_EX] ) ) { + $checkEXKeys[] = $key; // this has no EX lock on $key itself + } + } + + # Block new writers (both EX and SH locks leave entries here)... + $db->insert( 'filelocks_shared', $data, __METHOD__, [ 'IGNORE' ] ); + # Actually do the locking queries... + if ( $type == self::LOCK_SH ) { // reader locks + # Bail if there are any existing writers... + if ( count( $checkEXKeys ) ) { + $blocked = $db->selectField( + 'filelocks_exclusive', + '1', + [ 'fle_key' => $checkEXKeys ], + __METHOD__ + ); + } else { + $blocked = false; + } + # Other prospective writers that haven't yet updated filelocks_exclusive + # will recheck filelocks_shared after doing so and bail due to this entry. + } else { // writer locks + $encSession = $db->addQuotes( $this->session ); + # Bail if there are any existing writers... + # This may detect readers, but the safe check for them is below. + # Note: if two writers come at the same time, both bail :) + $blocked = $db->selectField( + 'filelocks_shared', + '1', + [ 'fls_key' => $keys, "fls_session != $encSession" ], + __METHOD__ + ); + if ( !$blocked ) { + # Build up values for INSERT clause + $data = []; + foreach ( $keys as $key ) { + $data[] = [ 'fle_key' => $key ]; + } + # Block new readers/writers... + $db->insert( 'filelocks_exclusive', $data, __METHOD__ ); + # Bail if there are any existing readers... + $blocked = $db->selectField( + 'filelocks_shared', + '1', + [ 'fls_key' => $keys, "fls_session != $encSession" ], + __METHOD__ + ); + } + } + + if ( $blocked ) { + foreach ( $paths as $path ) { + $status->fatal( 'lockmanager-fail-acquirelock', $path ); + } + } + + return $status; + } + + /** + * @see QuorumLockManager::releaseAllLocks() + * @return StatusValue + */ + protected function releaseAllLocks() { + $status = StatusValue::newGood(); + + foreach ( $this->conns as $lockDb => $db ) { + if ( $db->trxLevel() ) { // in transaction + try { + $db->rollback( __METHOD__ ); // finish transaction and kill any rows + } catch ( DBError $e ) { + $status->fatal( 'lockmanager-fail-db-release', $lockDb ); + } + } + } + + return $status; + } +} diff --git a/includes/filebackend/lockmanager/QuorumLockManager.php b/includes/filebackend/lockmanager/QuorumLockManager.php deleted file mode 100644 index 108b8465cf..0000000000 --- a/includes/filebackend/lockmanager/QuorumLockManager.php +++ /dev/null @@ -1,248 +0,0 @@ - (lsrv1, lsrv2, ...)) - - /** @var array Map of degraded buckets */ - protected $degradedBuckets = []; // (buckey index => UNIX timestamp) - - final protected function doLock( array $paths, $type ) { - return $this->doLockByType( [ $type => $paths ] ); - } - - final protected function doUnlock( array $paths, $type ) { - return $this->doUnlockByType( [ $type => $paths ] ); - } - - protected function doLockByType( array $pathsByType ) { - $status = Status::newGood(); - - $pathsToLock = []; // (bucket => type => paths) - // Get locks that need to be acquired (buckets => locks)... - foreach ( $pathsByType as $type => $paths ) { - foreach ( $paths as $path ) { - if ( isset( $this->locksHeld[$path][$type] ) ) { - ++$this->locksHeld[$path][$type]; - } else { - $bucket = $this->getBucketFromPath( $path ); - $pathsToLock[$bucket][$type][] = $path; - } - } - } - - $lockedPaths = []; // files locked in this attempt (type => paths) - // Attempt to acquire these locks... - foreach ( $pathsToLock as $bucket => $pathsToLockByType ) { - // Try to acquire the locks for this bucket - $status->merge( $this->doLockingRequestBucket( $bucket, $pathsToLockByType ) ); - if ( !$status->isOK() ) { - $status->merge( $this->doUnlockByType( $lockedPaths ) ); - - return $status; - } - // Record these locks as active - foreach ( $pathsToLockByType as $type => $paths ) { - foreach ( $paths as $path ) { - $this->locksHeld[$path][$type] = 1; // locked - // Keep track of what locks were made in this attempt - $lockedPaths[$type][] = $path; - } - } - } - - return $status; - } - - protected function doUnlockByType( array $pathsByType ) { - $status = Status::newGood(); - - $pathsToUnlock = []; // (bucket => type => paths) - foreach ( $pathsByType as $type => $paths ) { - foreach ( $paths as $path ) { - if ( !isset( $this->locksHeld[$path][$type] ) ) { - $status->warning( 'lockmanager-notlocked', $path ); - } else { - --$this->locksHeld[$path][$type]; - // Reference count the locks held and release locks when zero - if ( $this->locksHeld[$path][$type] <= 0 ) { - unset( $this->locksHeld[$path][$type] ); - $bucket = $this->getBucketFromPath( $path ); - $pathsToUnlock[$bucket][$type][] = $path; - } - if ( !count( $this->locksHeld[$path] ) ) { - unset( $this->locksHeld[$path] ); // no SH or EX locks left for key - } - } - } - } - - // Remove these specific locks if possible, or at least release - // all locks once this process is currently not holding any locks. - foreach ( $pathsToUnlock as $bucket => $pathsToUnlockByType ) { - $status->merge( $this->doUnlockingRequestBucket( $bucket, $pathsToUnlockByType ) ); - } - if ( !count( $this->locksHeld ) ) { - $status->merge( $this->releaseAllLocks() ); - $this->degradedBuckets = []; // safe to retry the normal quorum - } - - return $status; - } - - /** - * Attempt to acquire locks with the peers for a bucket. - * This is all or nothing; if any key is locked then this totally fails. - * - * @param int $bucket - * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths - * @return Status - */ - final protected function doLockingRequestBucket( $bucket, array $pathsByType ) { - $status = Status::newGood(); - - $yesVotes = 0; // locks made on trustable servers - $votesLeft = count( $this->srvsByBucket[$bucket] ); // remaining peers - $quorum = floor( $votesLeft / 2 + 1 ); // simple majority - // Get votes for each peer, in order, until we have enough... - foreach ( $this->srvsByBucket[$bucket] as $lockSrv ) { - if ( !$this->isServerUp( $lockSrv ) ) { - --$votesLeft; - $status->warning( 'lockmanager-fail-svr-acquire', $lockSrv ); - $this->degradedBuckets[$bucket] = time(); - continue; // server down? - } - // Attempt to acquire the lock on this peer - $status->merge( $this->getLocksOnServer( $lockSrv, $pathsByType ) ); - if ( !$status->isOK() ) { - return $status; // vetoed; resource locked - } - ++$yesVotes; // success for this peer - if ( $yesVotes >= $quorum ) { - return $status; // lock obtained - } - --$votesLeft; - $votesNeeded = $quorum - $yesVotes; - if ( $votesNeeded > $votesLeft ) { - break; // short-circuit - } - } - // At this point, we must not have met the quorum - $status->setResult( false ); - - return $status; - } - - /** - * Attempt to release locks with the peers for a bucket - * - * @param int $bucket - * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths - * @return Status - */ - final protected function doUnlockingRequestBucket( $bucket, array $pathsByType ) { - $status = Status::newGood(); - - $yesVotes = 0; // locks freed on trustable servers - $votesLeft = count( $this->srvsByBucket[$bucket] ); // remaining peers - $quorum = floor( $votesLeft / 2 + 1 ); // simple majority - $isDegraded = isset( $this->degradedBuckets[$bucket] ); // not the normal quorum? - foreach ( $this->srvsByBucket[$bucket] as $lockSrv ) { - if ( !$this->isServerUp( $lockSrv ) ) { - $status->warning( 'lockmanager-fail-svr-release', $lockSrv ); - } else { - // Attempt to release the lock on this peer - $status->merge( $this->freeLocksOnServer( $lockSrv, $pathsByType ) ); - ++$yesVotes; // success for this peer - // Normally the first peers form the quorum, and the others are ignored. - // Ignore them in this case, but not when an alternative quorum was used. - if ( $yesVotes >= $quorum && !$isDegraded ) { - break; // lock released - } - } - } - // Set a bad status if the quorum was not met. - // Assumes the same "up" servers as during the acquire step. - $status->setResult( $yesVotes >= $quorum ); - - return $status; - } - - /** - * Get the bucket for resource path. - * This should avoid throwing any exceptions. - * - * @param string $path - * @return int - */ - protected function getBucketFromPath( $path ) { - $prefix = substr( sha1( $path ), 0, 2 ); // first 2 hex chars (8 bits) - return (int)base_convert( $prefix, 16, 10 ) % count( $this->srvsByBucket ); - } - - /** - * Check if a lock server is up. - * This should process cache results to reduce RTT. - * - * @param string $lockSrv - * @return bool - */ - abstract protected function isServerUp( $lockSrv ); - - /** - * Get a connection to a lock server and acquire locks - * - * @param string $lockSrv - * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths - * @return Status - */ - abstract protected function getLocksOnServer( $lockSrv, array $pathsByType ); - - /** - * Get a connection to a lock server and release locks on $paths. - * - * Subclasses must effectively implement this or releaseAllLocks(). - * - * @param string $lockSrv - * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths - * @return Status - */ - abstract protected function freeLocksOnServer( $lockSrv, array $pathsByType ); - - /** - * Release all locks that this session is holding. - * - * Subclasses must effectively implement this or freeLocksOnServer(). - * - * @return Status - */ - abstract protected function releaseAllLocks(); -} diff --git a/includes/filebackend/lockmanager/RedisLockManager.php b/includes/filebackend/lockmanager/RedisLockManager.php deleted file mode 100644 index 6095aeede4..0000000000 --- a/includes/filebackend/lockmanager/RedisLockManager.php +++ /dev/null @@ -1,272 +0,0 @@ - self::LOCK_SH, - self::LOCK_UW => self::LOCK_SH, - self::LOCK_EX => self::LOCK_EX - ]; - - /** @var RedisConnectionPool */ - protected $redisPool; - - /** @var array Map server names to hostname/IP and port numbers */ - protected $lockServers = []; - - /** @var string Random UUID */ - protected $session = ''; - - /** - * Construct a new instance from configuration. - * - * @param array $config Parameters include: - * - lockServers : Associative array of server names to ":" strings. - * - srvsByBucket : Array of 1-16 consecutive integer keys, starting from 0, - * each having an odd-numbered list of server names (peers) as values. - * - redisConfig : Configuration for RedisConnectionPool::__construct(). - * @throws Exception - */ - public function __construct( array $config ) { - parent::__construct( $config ); - - $this->lockServers = $config['lockServers']; - // Sanitize srvsByBucket config to prevent PHP errors - $this->srvsByBucket = array_filter( $config['srvsByBucket'], 'is_array' ); - $this->srvsByBucket = array_values( $this->srvsByBucket ); // consecutive - - $config['redisConfig']['serializer'] = 'none'; - $this->redisPool = RedisConnectionPool::singleton( $config['redisConfig'] ); - - $this->session = wfRandomString( 32 ); - } - - protected function getLocksOnServer( $lockSrv, array $pathsByType ) { - $status = Status::newGood(); - - $server = $this->lockServers[$lockSrv]; - $conn = $this->redisPool->getConnection( $server ); - if ( !$conn ) { - foreach ( array_merge( array_values( $pathsByType ) ) as $path ) { - $status->fatal( 'lockmanager-fail-acquirelock', $path ); - } - - return $status; - } - - $pathsByKey = []; // (type:hash => path) map - foreach ( $pathsByType as $type => $paths ) { - $typeString = ( $type == LockManager::LOCK_SH ) ? 'SH' : 'EX'; - foreach ( $paths as $path ) { - $pathsByKey[$this->recordKeyForPath( $path, $typeString )] = $path; - } - } - - try { - static $script = -<<luaEval( $script, - array_merge( - array_keys( $pathsByKey ), // KEYS[0], KEYS[1],...,KEYS[N] - [ - $this->session, // ARGV[1] - $this->lockTTL, // ARGV[2] - time() // ARGV[3] - ] - ), - count( $pathsByKey ) # number of first argument(s) that are keys - ); - } catch ( RedisException $e ) { - $res = false; - $this->redisPool->handleError( $conn, $e ); - } - - if ( $res === false ) { - foreach ( array_merge( array_values( $pathsByType ) ) as $path ) { - $status->fatal( 'lockmanager-fail-acquirelock', $path ); - } - } else { - foreach ( $res as $key ) { - $status->fatal( 'lockmanager-fail-acquirelock', $pathsByKey[$key] ); - } - } - - return $status; - } - - protected function freeLocksOnServer( $lockSrv, array $pathsByType ) { - $status = Status::newGood(); - - $server = $this->lockServers[$lockSrv]; - $conn = $this->redisPool->getConnection( $server ); - if ( !$conn ) { - foreach ( array_merge( array_values( $pathsByType ) ) as $path ) { - $status->fatal( 'lockmanager-fail-releaselock', $path ); - } - - return $status; - } - - $pathsByKey = []; // (type:hash => path) map - foreach ( $pathsByType as $type => $paths ) { - $typeString = ( $type == LockManager::LOCK_SH ) ? 'SH' : 'EX'; - foreach ( $paths as $path ) { - $pathsByKey[$this->recordKeyForPath( $path, $typeString )] = $path; - } - } - - try { - static $script = -<< 0 then - -- Remove the whole structure if it is now empty - if redis.call('hLen',resourceKey) == 0 then - redis.call('del',resourceKey) - end - else - failed[#failed+1] = requestKey - end - end - return failed -LUA; - $res = $conn->luaEval( $script, - array_merge( - array_keys( $pathsByKey ), // KEYS[0], KEYS[1],...,KEYS[N] - [ - $this->session, // ARGV[1] - ] - ), - count( $pathsByKey ) # number of first argument(s) that are keys - ); - } catch ( RedisException $e ) { - $res = false; - $this->redisPool->handleError( $conn, $e ); - } - - if ( $res === false ) { - foreach ( array_merge( array_values( $pathsByType ) ) as $path ) { - $status->fatal( 'lockmanager-fail-releaselock', $path ); - } - } else { - foreach ( $res as $key ) { - $status->fatal( 'lockmanager-fail-releaselock', $pathsByKey[$key] ); - } - } - - return $status; - } - - protected function releaseAllLocks() { - return Status::newGood(); // not supported - } - - protected function isServerUp( $lockSrv ) { - return (bool)$this->redisPool->getConnection( $this->lockServers[$lockSrv] ); - } - - /** - * @param string $path - * @param string $type One of (EX,SH) - * @return string - */ - protected function recordKeyForPath( $path, $type ) { - return implode( ':', - [ __CLASS__, 'locks', "$type:" . $this->sha1Base36Absolute( $path ) ] ); - } - - /** - * Make sure remaining locks get cleared for sanity - */ - function __destruct() { - while ( count( $this->locksHeld ) ) { - $pathsByType = []; - foreach ( $this->locksHeld as $path => $locks ) { - foreach ( $locks as $type => $count ) { - $pathsByType[$type][] = $path; - } - } - $this->unlockByType( $pathsByType ); - } - } -} diff --git a/includes/filebackend/lockmanager/ScopedLock.php b/includes/filebackend/lockmanager/ScopedLock.php deleted file mode 100644 index e1a600ce11..0000000000 --- a/includes/filebackend/lockmanager/ScopedLock.php +++ /dev/null @@ -1,105 +0,0 @@ -manager = $manager; - $this->pathsByType = $pathsByType; - $this->status = $status; - } - - /** - * Get a ScopedLock object representing a lock on resource paths. - * Any locks are released once this object goes out of scope. - * The status object is updated with any errors or warnings. - * - * @param LockManager $manager - * @param array $paths List of storage paths or map of lock types to path lists - * @param int|string $type LockManager::LOCK_* constant or "mixed" and $paths - * can be a map of types to paths (since 1.22). Otherwise $type should be an - * integer and $paths should be a list of paths. - * @param Status $status - * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.22) - * @return ScopedLock|null Returns null on failure - */ - public static function factory( - LockManager $manager, array $paths, $type, Status $status, $timeout = 0 - ) { - $pathsByType = is_integer( $type ) ? [ $type => $paths ] : $paths; - $lockStatus = $manager->lockByType( $pathsByType, $timeout ); - $status->merge( $lockStatus ); - if ( $lockStatus->isOK() ) { - return new self( $manager, $pathsByType, $status ); - } - - return null; - } - - /** - * Release a scoped lock and set any errors in the attatched Status object. - * This is useful for early release of locks before function scope is destroyed. - * This is the same as setting the lock object to null. - * - * @param ScopedLock $lock - * @since 1.21 - */ - public static function release( ScopedLock &$lock = null ) { - $lock = null; - } - - /** - * Release the locks when this goes out of scope - */ - function __destruct() { - $wasOk = $this->status->isOK(); - $this->status->merge( $this->manager->unlockByType( $this->pathsByType ) ); - if ( $wasOk ) { - // Make sure status is OK, despite any unlockFiles() fatals - $this->status->setResult( true, $this->status->value ); - } - } -} diff --git a/includes/filerepo/FSRepo.php b/includes/filerepo/FSRepo.php index b24354dc4d..d06acf27ba 100644 --- a/includes/filerepo/FSRepo.php +++ b/includes/filerepo/FSRepo.php @@ -66,6 +66,7 @@ class FSRepo extends FileRepo { "{$repoName}-deleted" => $deletedDir ], 'fileMode' => $fileMode, + 'tmpDirectory' => wfTempDir() ] ); // Update repo config to use this backend $info['backend'] = $backend; diff --git a/includes/filerepo/FileBackendDBRepoWrapper.php b/includes/filerepo/FileBackendDBRepoWrapper.php index aec337e404..5bc60a0e0a 100644 --- a/includes/filerepo/FileBackendDBRepoWrapper.php +++ b/includes/filerepo/FileBackendDBRepoWrapper.php @@ -27,7 +27,7 @@ * @brief Proxy backend that manages file layout rewriting for FileRepo. * * LocalRepo may be configured to store files under their title names or by SHA-1. - * This acts as a shim in the later case, providing backwards compatability for + * This acts as a shim in the latter case, providing backwards compatability for * most callers. All "public"/"deleted" zone files actually go in an "original" * container and are never changed. * @@ -50,8 +50,10 @@ class FileBackendDBRepoWrapper extends FileBackend { protected $dbs; public function __construct( array $config ) { - $config['name'] = $config['backend']->getName(); - $config['wikiId'] = $config['backend']->getWikiId(); + /** @var FileBackend $backend */ + $backend = $config['backend']; + $config['name'] = $backend->getName(); + $config['wikiId'] = $backend->getWikiId(); parent::__construct( $config ); $this->backend = $config['backend']; $this->repoName = $config['repoName']; @@ -94,7 +96,7 @@ class FileBackendDBRepoWrapper extends FileBackend { * @return array Translated paths in same order */ public function getBackendPaths( array $paths, $latest = true ) { - $db = $this->getDB( $latest ? DB_MASTER : DB_SLAVE ); + $db = $this->getDB( $latest ? DB_MASTER : DB_REPLICA ); // @TODO: batching $resolved = []; @@ -256,7 +258,7 @@ class FileBackendDBRepoWrapper extends FileBackend { return $this->translateSrcParams( __FUNCTION__, $params ); } - public function getScopedLocksForOps( array $ops, Status $status ) { + public function getScopedLocksForOps( array $ops, StatusValue $status ) { return $this->backend->getScopedLocksForOps( $ops, $status ); } diff --git a/includes/filerepo/FileRepo.php b/includes/filerepo/FileRepo.php index 9ad24283c9..66dab99217 100644 --- a/includes/filerepo/FileRepo.php +++ b/includes/filerepo/FileRepo.php @@ -393,7 +393,7 @@ class FileRepo { if ( $this->oldFileFactory ) { return call_user_func( $this->oldFileFactory, $title, $this, $time ); } else { - return false; + return null; } } else { return call_user_func( $this->fileFactory, $title, $this ); @@ -482,8 +482,8 @@ class FileRepo { * @param array $items An array of titles, or an array of findFile() options with * the "title" option giving the title. Example: * - * $findItem = array( 'title' => $title, 'private' => true ); - * $findBatch = array( $findItem ); + * $findItem = [ 'title' => $title, 'private' => true ]; + * $findBatch = [ $findItem ]; * $repo->findFiles( $findBatch ); * * No title should appear in $items twice, as the result use titles as keys @@ -533,11 +533,10 @@ class FileRepo { public function findFileFromKey( $sha1, $options = [] ) { $time = isset( $options['time'] ) ? $options['time'] : false; # First try to find a matching current version of a file... - if ( $this->fileFactoryKey ) { - $img = call_user_func( $this->fileFactoryKey, $sha1, $this, $time ); - } else { + if ( !$this->fileFactoryKey ) { return false; // find-by-sha1 not supported } + $img = call_user_func( $this->fileFactoryKey, $sha1, $this, $time ); if ( $img && $img->exists() ) { return $img; } @@ -819,14 +818,14 @@ class FileRepo { * self::OVERWRITE_SAME Overwrite the file if the destination exists and has the * same contents as the source * self::SKIP_LOCKING Skip any file locking when doing the store - * @return FileRepoStatus + * @return Status */ public function store( $srcPath, $dstZone, $dstRel, $flags = 0 ) { $this->assertWritableRepo(); // fail out if read-only $status = $this->storeBatch( [ [ $srcPath, $dstZone, $dstRel ] ], $flags ); if ( $status->successCount == 0 ) { - $status->ok = false; + $status->setOK( false ); } return $status; @@ -842,7 +841,7 @@ class FileRepo { * same contents as the source * self::SKIP_LOCKING Skip any file locking when doing the store * @throws MWException - * @return FileRepoStatus + * @return Status */ public function storeBatch( array $triplets, $flags = 0 ) { $this->assertWritableRepo(); // fail out if read-only @@ -913,7 +912,7 @@ class FileRepo { * @param array $files List of files to delete * @param int $flags Bitwise combination of the following flags: * self::SKIP_LOCKING Skip any file locking when doing the deletions - * @return FileRepoStatus + * @return Status */ public function cleanupBatch( array $files, $flags = 0 ) { $this->assertWritableRepo(); // fail out if read-only @@ -953,7 +952,7 @@ class FileRepo { * @param array|string|null $options An array consisting of a key named headers * listing extra headers. If a string, taken as content-disposition header. * (Support for array of options new in 1.23) - * @return FileRepoStatus + * @return Status */ final public function quickImport( $src, $dst, $options = null ) { return $this->quickImportBatch( [ [ $src, $dst, $options ] ] ); @@ -965,7 +964,7 @@ class FileRepo { * This is intended for purging thumbnails. * * @param string $path Virtual URL or storage path - * @return FileRepoStatus + * @return Status */ final public function quickPurge( $path ) { return $this->quickPurgeBatch( [ $path ] ); @@ -996,7 +995,7 @@ class FileRepo { * When "headers" are given they are used as HTTP headers if supported. * * @param array $triples List of (source path or FSFile, destination path, disposition) - * @return FileRepoStatus + * @return Status */ public function quickImportBatch( array $triples ) { $status = $this->newGood(); @@ -1041,7 +1040,7 @@ class FileRepo { * This does no locking nor journaling and is intended for purging thumbnails. * * @param array $paths List of virtual URLs or storage paths - * @return FileRepoStatus + * @return Status */ public function quickPurgeBatch( array $paths ) { $status = $this->newGood(); @@ -1066,7 +1065,7 @@ class FileRepo { * @param string $originalName The base name of the file as specified * by the user. The file extension will be maintained. * @param string $srcPath The current location of the file. - * @return FileRepoStatus Object with the URL in the value. + * @return Status Object with the URL in the value. */ public function storeTemp( $originalName, $srcPath ) { $this->assertWritableRepo(); // fail out if read-only @@ -1108,7 +1107,7 @@ class FileRepo { * @param string $dstPath Target file system path * @param int $flags Bitwise combination of the following flags: * self::DELETE_SOURCE Delete the source files on success - * @return FileRepoStatus + * @return Status */ public function concatenate( array $srcPaths, $dstPath, $flags = 0 ) { $this->assertWritableRepo(); // fail out if read-only @@ -1157,7 +1156,7 @@ class FileRepo { * @param int $flags Bitfield, may be FileRepo::DELETE_SOURCE to indicate * that the source file should be deleted if possible * @param array $options Optional additional parameters - * @return FileRepoStatus + * @return Status */ public function publish( $src, $dstRel, $archiveRel, $flags = 0, array $options = [] @@ -1167,7 +1166,7 @@ class FileRepo { $status = $this->publishBatch( [ [ $src, $dstRel, $archiveRel, $options ] ], $flags ); if ( $status->successCount == 0 ) { - $status->ok = false; + $status->setOK( false ); } if ( isset( $status->value[0] ) ) { $status->value = $status->value[0]; @@ -1186,7 +1185,7 @@ class FileRepo { * @param int $flags Bitfield, may be FileRepo::DELETE_SOURCE to indicate * that the source files should be deleted if possible * @throws MWException - * @return FileRepoStatus + * @return Status */ public function publishBatch( array $ntuples, $flags = 0 ) { $this->assertWritableRepo(); // fail out if read-only @@ -1323,7 +1322,10 @@ class FileRepo { $params = [ 'noAccess' => true, 'noListing' => true ] + $params; } - return $this->backend->prepare( $params ); + $status = $this->newGood(); + $status->merge( $this->backend->prepare( $params ) ); + + return $status; } /** @@ -1381,7 +1383,7 @@ class FileRepo { * @param mixed $srcRel Relative path for the file to be deleted * @param mixed $archiveRel Relative path for the archive location. * Relative to a private archive directory. - * @return FileRepoStatus + * @return Status */ public function delete( $srcRel, $archiveRel ) { $this->assertWritableRepo(); // fail out if read-only @@ -1404,7 +1406,7 @@ class FileRepo { * public root in the first element, and the archive file path relative * to the deleted zone root in the second element. * @throws MWException - * @return FileRepoStatus + * @return Status */ public function deleteBatch( array $sourceDestPairs ) { $this->assertWritableRepo(); // fail out if read-only @@ -1540,9 +1542,15 @@ class FileRepo { * @return array */ public function getFileProps( $virtualUrl ) { - $path = $this->resolveToStoragePath( $virtualUrl ); + $fsFile = $this->getLocalReference( $virtualUrl ); + $mwProps = new MWFileProps( MimeMagic::singleton() ); + if ( $fsFile ) { + $props = $mwProps->getPropsFromPath( $fsFile->getPath(), true ); + } else { + $props = $mwProps->newPlaceholderProps(); + } - return $this->backend->getFileProps( [ 'src' => $path ] ); + return $props; } /** @@ -1586,14 +1594,18 @@ class FileRepo { * * @param string $virtualUrl * @param array $headers Additional HTTP headers to send on success + * @param array $optHeaders HTTP request headers (if-modified-since, range, ...) * @return Status * @since 1.27 */ - public function streamFileWithStatus( $virtualUrl, $headers = [] ) { + public function streamFileWithStatus( $virtualUrl, $headers = [], $optHeaders = [] ) { $path = $this->resolveToStoragePath( $virtualUrl ); - $params = [ 'src' => $path, 'headers' => $headers ]; + $params = [ 'src' => $path, 'headers' => $headers, 'options' => $optHeaders ]; - return $this->backend->streamFile( $params ); + $status = $this->newGood(); + $status->merge( $this->backend->streamFile( $params ) ); + + return $status; } /** diff --git a/includes/filerepo/FileRepoStatus.php b/includes/filerepo/FileRepoStatus.php index 67080b6f83..538e9bc9da 100644 --- a/includes/filerepo/FileRepoStatus.php +++ b/includes/filerepo/FileRepoStatus.php @@ -24,7 +24,7 @@ /** * Generic operation result class for FileRepo-related operations * @ingroup FileRepo - * @deprecated 1.25 + * @deprecated since 1.25 */ class FileRepoStatus extends Status { } diff --git a/includes/filerepo/ForeignAPIRepo.php b/includes/filerepo/ForeignAPIRepo.php index b48191fd1c..4176c8240e 100644 --- a/includes/filerepo/ForeignAPIRepo.php +++ b/includes/filerepo/ForeignAPIRepo.php @@ -28,13 +28,13 @@ use MediaWiki\Logger\LoggerFactory; * * Example config: * - * $wgForeignFileRepos[] = array( + * $wgForeignFileRepos[] = [ * 'class' => 'ForeignAPIRepo', * 'name' => 'shared', * 'apibase' => 'https://en.wikipedia.org/w/api.php', * 'fetchDescription' => true, // Optional * 'descriptionCacheExpiry' => 3600, - * ); + * ]; * * @ingroup FileRepo */ @@ -63,8 +63,8 @@ class ForeignAPIRepo extends FileRepo { /** @var array */ protected $mFileExists = []; - /** @var array */ - private $mQueryCache = []; + /** @var string */ + private $mApiBase; /** * @param array|null $info @@ -397,7 +397,8 @@ class ForeignAPIRepo extends FileRepo { } /* There is a new Commons file, or existing thumbnail older than a month */ } - $thumb = self::httpGet( $foreignUrl ); + + $thumb = self::httpGet( $foreignUrl, 'default', [], $mtime ); if ( !$thumb ) { wfDebug( __METHOD__ . " Could not download thumb\n" ); @@ -413,7 +414,11 @@ class ForeignAPIRepo extends FileRepo { return $foreignUrl; } $knownThumbUrls[$sizekey] = $localUrl; - $cache->set( $key, $knownThumbUrls, $this->apiThumbCacheExpiry ); + + $ttl = $mtime + ? $cache->adaptiveTTL( $mtime, $this->apiThumbCacheExpiry ) + : $this->apiThumbCacheExpiry; + $cache->set( $key, $knownThumbUrls, $ttl ); wfDebug( __METHOD__ . " got local thumb $localUrl, saving to cache \n" ); return $localUrl; @@ -506,9 +511,12 @@ class ForeignAPIRepo extends FileRepo { * @param string $url * @param string $timeout * @param array $options + * @param integer|bool &$mtime Resulting Last-Modified UNIX timestamp if received * @return bool|string */ - public static function httpGet( $url, $timeout = 'default', $options = [] ) { + public static function httpGet( + $url, $timeout = 'default', $options = [], &$mtime = false + ) { $options['timeout'] = $timeout; /* Http::get */ $url = wfExpandUrl( $url, PROTO_HTTP ); @@ -524,6 +532,9 @@ class ForeignAPIRepo extends FileRepo { $status = $req->execute(); if ( $status->isOK() ) { + $lmod = $req->getResponseHeader( 'Last-Modified' ); + $mtime = $lmod ? wfTimestamp( TS_UNIX, $lmod ) : false; + return $req->getContent(); } else { $logger = LoggerFactory::getInstance( 'http' ); @@ -531,6 +542,7 @@ class ForeignAPIRepo extends FileRepo { $status->getWikiText( false, false, 'en' ), [ 'caller' => 'ForeignAPIRepo::httpGet' ] ); + return false; } } @@ -548,7 +560,7 @@ class ForeignAPIRepo extends FileRepo { * @param string $target Used in cache key creation, mostly * @param array $query The query parameters for the API request * @param int $cacheTTL Time to live for the memcached caching - * @return null + * @return string|null */ public function httpGetCached( $target, $query, $cacheTTL = 3600 ) { if ( $this->mApiBase ) { @@ -557,28 +569,23 @@ class ForeignAPIRepo extends FileRepo { $url = $this->makeUrl( $query, 'api' ); } - if ( !isset( $this->mQueryCache[$url] ) ) { - $data = ObjectCache::getMainWANInstance()->getWithSetCallback( - $this->getLocalCacheKey( get_class( $this ), $target, md5( $url ) ), - $cacheTTL, - function () use ( $url ) { - return ForeignAPIRepo::httpGet( $url ); + $cache = ObjectCache::getMainWANInstance(); + return $cache->getWithSetCallback( + $this->getLocalCacheKey( get_class( $this ), $target, md5( $url ) ), + $cacheTTL, + function ( $curValue, &$ttl ) use ( $url, $cache ) { + $html = self::httpGet( $url, 'default', [], $mtime ); + if ( $html !== false ) { + $ttl = $mtime ? $cache->adaptiveTTL( $mtime, $ttl ) : $ttl; + } else { + $ttl = $cache->adaptiveTTL( $mtime, $ttl ); + $html = null; // caches negatives } - ); - if ( !$data ) { - return null; - } - - if ( count( $this->mQueryCache ) > 100 ) { - // Keep the cache from growing infinitely - $this->mQueryCache = []; - } - - $this->mQueryCache[$url] = $data; - } - - return $this->mQueryCache[$url]; + return $html; + }, + [ 'pcTTL' => $cache::TTL_PROC_LONG ] + ); } /** diff --git a/includes/filerepo/ForeignDBRepo.php b/includes/filerepo/ForeignDBRepo.php index 001800f3ba..f49ef88c5c 100644 --- a/includes/filerepo/ForeignDBRepo.php +++ b/includes/filerepo/ForeignDBRepo.php @@ -51,9 +51,12 @@ class ForeignDBRepo extends LocalRepo { /** @var bool */ protected $hasSharedCache; - # Other stuff + /** @var IDatabase */ protected $dbConn; + + /** @var callable */ protected $fileFactory = [ 'ForeignDBFile', 'newFromTitle' ]; + /** @var callable */ protected $fileFromRowFactory = [ 'ForeignDBFile', 'newFromRow' ]; /** @@ -86,7 +89,7 @@ class ForeignDBRepo extends LocalRepo { /** * @return IDatabase */ - function getSlaveDB() { + function getReplicaDB() { return $this->getMasterDB(); } @@ -106,7 +109,7 @@ class ForeignDBRepo extends LocalRepo { ]; return function ( $index ) use ( $type, $params ) { - return DatabaseBase::factory( $type, $params ); + return Database::factory( $type, $params ); }; } diff --git a/includes/filerepo/ForeignDBViaLBRepo.php b/includes/filerepo/ForeignDBViaLBRepo.php index a59ca34a04..a9cd030869 100644 --- a/includes/filerepo/ForeignDBViaLBRepo.php +++ b/includes/filerepo/ForeignDBViaLBRepo.php @@ -42,6 +42,9 @@ class ForeignDBViaLBRepo extends LocalRepo { /** @var array */ protected $fileFromRowFactory = [ 'ForeignDBFile', 'newFromRow' ]; + /** @var bool */ + protected $hasSharedCache; + /** * @param array|null $info */ @@ -56,23 +59,22 @@ class ForeignDBViaLBRepo extends LocalRepo { * @return IDatabase */ function getMasterDB() { - return wfGetDB( DB_MASTER, [], $this->wiki ); + return wfGetLB( $this->wiki )->getConnectionRef( DB_MASTER, [], $this->wiki ); } /** * @return IDatabase */ - function getSlaveDB() { - return wfGetDB( DB_SLAVE, [], $this->wiki ); + function getReplicaDB() { + return wfGetLB( $this->wiki )->getConnectionRef( DB_REPLICA, [], $this->wiki ); } /** * @return Closure */ protected function getDBFactory() { - $wiki = $this->wiki; - return function( $index ) use ( $wiki ) { - return wfGetDB( $index, [], $wiki ); + return function( $index ) { + return wfGetLB( $this->wiki )->getConnectionRef( $index, [], $this->wiki ); }; } diff --git a/includes/filerepo/LocalRepo.php b/includes/filerepo/LocalRepo.php index eaec15129c..d49ae7bf4b 100644 --- a/includes/filerepo/LocalRepo.php +++ b/includes/filerepo/LocalRepo.php @@ -29,28 +29,24 @@ * @ingroup FileRepo */ class LocalRepo extends FileRepo { - /** @var array */ + /** @var callable */ protected $fileFactory = [ 'LocalFile', 'newFromTitle' ]; - - /** @var array */ + /** @var callable */ protected $fileFactoryKey = [ 'LocalFile', 'newFromKey' ]; - - /** @var array */ + /** @var callable */ protected $fileFromRowFactory = [ 'LocalFile', 'newFromRow' ]; - - /** @var array */ + /** @var callable */ protected $oldFileFromRowFactory = [ 'OldLocalFile', 'newFromRow' ]; - - /** @var array */ + /** @var callable */ protected $oldFileFactory = [ 'OldLocalFile', 'newFromTitle' ]; - - /** @var array */ + /** @var callable */ protected $oldFileFactoryKey = [ 'OldLocalFile', 'newFromKey' ]; function __construct( array $info = null ) { parent::__construct( $info ); - $this->hasSha1Storage = isset( $info['storageLayout'] ) && $info['storageLayout'] === 'sha1'; + $this->hasSha1Storage = isset( $info['storageLayout'] ) + && $info['storageLayout'] === 'sha1'; if ( $this->hasSha1Storage() ) { $this->backend = new FileBackendDBRepoWrapper( [ @@ -93,7 +89,7 @@ class LocalRepo extends FileRepo { * * @param array $storageKeys * - * @return FileRepoStatus + * @return Status */ function cleanupDeletedBatch( array $storageKeys ) { if ( $this->hasSha1Storage() ) { @@ -199,12 +195,12 @@ class LocalRepo extends FileRepo { $expiry = 86400; // has invalidation, 1 day } - $that = $this; + $method = __METHOD__; $redirDbKey = ObjectCache::getMainWANInstance()->getWithSetCallback( $memcKey, $expiry, - function ( $oldValue, &$ttl, array &$setOpts ) use ( $that, $title ) { - $dbr = $that->getSlaveDB(); // possibly remote DB + function ( $oldValue, &$ttl, array &$setOpts ) use ( $method, $title ) { + $dbr = $this->getReplicaDB(); // possibly remote DB $setOpts += Database::getCacheSetOptions( $dbr ); @@ -217,7 +213,7 @@ class LocalRepo extends FileRepo { 'page_title' => $title->getDBkey(), 'rd_from = page_id' ], - __METHOD__ + $method ); } else { $row = false; @@ -302,7 +298,7 @@ class LocalRepo extends FileRepo { } }; - $dbr = $this->getSlaveDB(); + $dbr = $this->getReplicaDB(); // Query image table $imgNames = []; @@ -372,7 +368,7 @@ class LocalRepo extends FileRepo { * @return File[] */ function findBySha1( $hash ) { - $dbr = $this->getSlaveDB(); + $dbr = $this->getReplicaDB(); $res = $dbr->select( 'image', LocalFile::selectFields(), @@ -404,7 +400,7 @@ class LocalRepo extends FileRepo { return []; // empty parameter } - $dbr = $this->getSlaveDB(); + $dbr = $this->getReplicaDB(); $res = $dbr->select( 'image', LocalFile::selectFields(), @@ -434,7 +430,7 @@ class LocalRepo extends FileRepo { $selectOptions = [ 'ORDER BY' => 'img_name', 'LIMIT' => intval( $limit ) ]; // Query database - $dbr = $this->getSlaveDB(); + $dbr = $this->getReplicaDB(); $res = $dbr->select( 'image', LocalFile::selectFields(), @@ -453,23 +449,33 @@ class LocalRepo extends FileRepo { } /** - * Get a connection to the slave DB - * @return DatabaseBase + * Get a connection to the replica DB + * @return IDatabase + */ + function getReplicaDB() { + return wfGetDB( DB_REPLICA ); + } + + /** + * Alias for getReplicaDB() + * + * @return IDatabase + * @deprecated Since 1.29 */ function getSlaveDB() { - return wfGetDB( DB_SLAVE ); + return $this->getReplicaDB(); } /** * Get a connection to the master DB - * @return DatabaseBase + * @return IDatabase */ function getMasterDB() { return wfGetDB( DB_MASTER ); } /** - * Get a callback to get a DB handle given an index (DB_SLAVE/DB_MASTER) + * Get a callback to get a DB handle given an index (DB_REPLICA/DB_MASTER) * @return Closure */ protected function getDBFactory() { @@ -500,9 +506,12 @@ class LocalRepo extends FileRepo { function invalidateImageRedirect( Title $title ) { $key = $this->getSharedCacheKey( 'image_redirect', md5( $title->getDBkey() ) ); if ( $key ) { - $this->getMasterDB()->onTransactionPreCommitOrIdle( function() use ( $key ) { - ObjectCache::getMainWANInstance()->delete( $key ); - } ); + $this->getMasterDB()->onTransactionPreCommitOrIdle( + function () use ( $key ) { + ObjectCache::getMainWANInstance()->delete( $key ); + }, + __METHOD__ + ); } } @@ -559,7 +568,7 @@ class LocalRepo extends FileRepo { * * @param string $function * @param array $args - * @return FileRepoStatus + * @return Status */ protected function skipWriteOperationIfSha1( $function, array $args ) { $this->assertWritableRepo(); // fail out if read-only diff --git a/includes/filerepo/RepoGroup.php b/includes/filerepo/RepoGroup.php index 08a40ebf24..d47624f8cd 100644 --- a/includes/filerepo/RepoGroup.php +++ b/includes/filerepo/RepoGroup.php @@ -135,17 +135,18 @@ class RepoGroup { } # Check the cache + $dbkey = $title->getDBkey(); if ( empty( $options['ignoreRedirect'] ) && empty( $options['private'] ) && empty( $options['bypassCache'] ) ) { $time = isset( $options['time'] ) ? $options['time'] : ''; - $dbkey = $title->getDBkey(); if ( $this->cache->has( $dbkey, $time, 60 ) ) { return $this->cache->get( $dbkey, $time ); } $useCache = true; } else { + $time = false; $useCache = false; } @@ -177,8 +178,8 @@ class RepoGroup { * @param array $inputItems An array of titles, or an array of findFile() options with * the "title" option giving the title. Example: * - * $findItem = array( 'title' => $title, 'private' => true ); - * $findBatch = array( $findItem ); + * $findItem = [ 'title' => $title, 'private' => true ]; + * $findBatch = [ $findItem ]; * $repo->findFiles( $findBatch ); * * No title should appear in $items twice, as the result use titles as keys @@ -451,7 +452,9 @@ class RepoGroup { return $repo->getFileProps( $fileName ); } else { - return FSFile::getPropsFromPath( $fileName ); + $mwProps = new MWFileProps( MimeMagic::singleton() ); + + return $mwProps->getPropsFromPath( $fileName, true ); } } diff --git a/includes/filerepo/file/ArchivedFile.php b/includes/filerepo/file/ArchivedFile.php index ca1ea84827..921e129c36 100644 --- a/includes/filerepo/file/ArchivedFile.php +++ b/includes/filerepo/file/ArchivedFile.php @@ -177,7 +177,7 @@ class ArchivedFile { if ( !$this->title || $this->title->getNamespace() == NS_FILE ) { $this->dataLoaded = true; // set it here, to have also true on miss - $dbr = wfGetDB( DB_SLAVE ); + $dbr = wfGetDB( DB_REPLICA ); $row = $dbr->selectRow( 'filearchive', self::selectFields(), @@ -425,6 +425,7 @@ class ArchivedFile { */ function pageCount() { if ( !isset( $this->pageCount ) ) { + // @FIXME: callers expect File objects if ( $this->getHandler() && $this->handler->isMultiPage( $this ) ) { $this->pageCount = $this->handler->pageCount( $this ); } else { diff --git a/includes/filerepo/file/File.php b/includes/filerepo/file/File.php index 8175b58cd0..9188cd9140 100644 --- a/includes/filerepo/file/File.php +++ b/includes/filerepo/file/File.php @@ -1018,7 +1018,7 @@ abstract class File implements IDBAccessObject { return $handler->getTransform( $this, $thumbPath, $thumbUrl, $params ); } else { return new MediaTransformError( 'thumbnail_error', - $params['width'], 0, wfMessage( 'thumbnail-dest-create' )->text() ); + $params['width'], 0, wfMessage( 'thumbnail-dest-create' ) ); } } @@ -1028,7 +1028,7 @@ abstract class File implements IDBAccessObject { * @param array $params An associative array of handler-specific parameters. * Typical keys are width, height and page. * @param int $flags A bitfield, may contain self::RENDER_NOW to force rendering - * @return MediaTransformOutput|bool False on failure + * @return ThumbnailImage|MediaTransformOutput|bool False on failure */ function transform( $params, $flags = 0 ) { global $wgThumbnailEpoch; @@ -1324,11 +1324,11 @@ abstract class File implements IDBAccessObject { /** * Creates a temp FS file with the same extension and the thumbnail * @param string $thumbPath Thumbnail path - * @return TempFSFile + * @return TempFSFile|null */ protected function makeTransformTmpFile( $thumbPath ) { $thumbExt = FileBackend::extensionFromPath( $thumbPath ); - return TempFSFile::factory( 'transform_', $thumbExt ); + return TempFSFile::factory( 'transform_', $thumbExt, wfTempDir() ); } /** @@ -1805,7 +1805,7 @@ abstract class File implements IDBAccessObject { * @param int $flags A bitwise combination of: * File::DELETE_SOURCE Delete the source file, i.e. move rather than copy * @param array $options Optional additional parameters - * @return FileRepoStatus On success, the value member contains the + * @return Status On success, the value member contains the * archive name, or an empty string if it was a new file. * * STUB @@ -1905,7 +1905,7 @@ abstract class File implements IDBAccessObject { * and logging are caller's responsibility * * @param Title $target New file name - * @return FileRepoStatus + * @return Status */ function move( $target ) { $this->readOnlyError(); @@ -1922,7 +1922,7 @@ abstract class File implements IDBAccessObject { * @param string $reason * @param bool $suppress Hide content from sysops? * @param User|null $user - * @return FileRepoStatus + * @return Status * STUB * Overridden by LocalFile */ diff --git a/includes/filerepo/file/ForeignAPIFile.php b/includes/filerepo/file/ForeignAPIFile.php index f6752d8308..43b6855f82 100644 --- a/includes/filerepo/file/ForeignAPIFile.php +++ b/includes/filerepo/file/ForeignAPIFile.php @@ -28,7 +28,10 @@ * @ingroup FileAbstraction */ class ForeignAPIFile extends File { + /** @var bool */ private $mExists; + /** @var array */ + private $mInfo = []; protected $repoClass = 'ForeignApiRepo'; @@ -244,7 +247,7 @@ class ForeignAPIFile extends File { public function getUser( $type = 'text' ) { if ( $type == 'text' ) { return isset( $this->mInfo['user'] ) ? strval( $this->mInfo['user'] ) : null; - } elseif ( $type == 'id' ) { + } else { return 0; // What makes sense here, for a remote user? } } @@ -344,9 +347,6 @@ class ForeignAPIFile extends File { return $files; } - /** - * @see File::purgeCache() - */ function purgeCache( $options = [] ) { $this->purgeThumbnails( $options ); $this->purgeDescriptionPage(); diff --git a/includes/filerepo/file/ForeignDBFile.php b/includes/filerepo/file/ForeignDBFile.php index cf0045e43f..df50a670a2 100644 --- a/includes/filerepo/file/ForeignDBFile.php +++ b/includes/filerepo/file/ForeignDBFile.php @@ -57,7 +57,7 @@ class ForeignDBFile extends LocalFile { * @param string $srcPath * @param int $flags * @param array $options - * @return FileRepoStatus + * @return Status * @throws MWException */ function publish( $srcPath, $flags = 0, array $options = [] ) { @@ -84,7 +84,7 @@ class ForeignDBFile extends LocalFile { /** * @param array $versions * @param bool $unsuppress - * @return FileRepoStatus + * @return Status * @throws MWException */ function restore( $versions = [], $unsuppress = false ) { @@ -95,7 +95,7 @@ class ForeignDBFile extends LocalFile { * @param string $reason * @param bool $suppress * @param User|null $user - * @return FileRepoStatus + * @return Status * @throws MWException */ function delete( $reason, $suppress = false, $user = null ) { @@ -104,7 +104,7 @@ class ForeignDBFile extends LocalFile { /** * @param Title $target - * @return FileRepoStatus + * @return Status * @throws MWException */ function move( $target ) { @@ -136,7 +136,7 @@ class ForeignDBFile extends LocalFile { return false; } - $touched = $this->repo->getSlaveDB()->selectField( + $touched = $this->repo->getReplicaDB()->selectField( 'page', 'page_touched', [ @@ -179,7 +179,7 @@ class ForeignDBFile extends LocalFile { * @since 1.27 */ public function getDescriptionShortUrl() { - $dbr = $this->repo->getSlaveDB(); + $dbr = $this->repo->getReplicaDB(); $pageId = $dbr->selectField( 'page', 'page_id', diff --git a/includes/filerepo/file/LocalFile.php b/includes/filerepo/file/LocalFile.php index f7275fc02c..011ba87ee7 100644 --- a/includes/filerepo/file/LocalFile.php +++ b/includes/filerepo/file/LocalFile.php @@ -21,10 +21,7 @@ * @ingroup FileAbstraction */ -/** - * Bump this number when serialized cache records may be incompatible. - */ -define( 'MW_FILE_VERSION', 9 ); +use \MediaWiki\Logger\LoggerFactory; /** * Class to represent a local file in the wiki's own database @@ -44,6 +41,8 @@ define( 'MW_FILE_VERSION', 9 ); * @ingroup FileAbstraction */ class LocalFile extends File { + const VERSION = 10; // cache version + const CACHE_FIELD_MAX_LEN = 1000; /** @var bool Does the file exist on disk? (loadFromXxx) */ @@ -115,6 +114,9 @@ class LocalFile extends File { /** @var bool Whether the row was upgraded on load */ private $upgraded; + /** @var bool Whether the row was scheduled to upgrade on load */ + private $upgrading; + /** @var bool True if the image row is locked */ private $locked; @@ -127,6 +129,8 @@ class LocalFile extends File { // @note: higher than IDBAccessObject constants const LOAD_ALL = 16; // integer; load all the lazy fields too (like metadata) + const ATOMIC_SECTION_LOCK = 'LocalFile::lockingTransaction'; + /** * Create a LocalFile from a title * Do not call this except from inside a repo class. @@ -170,7 +174,7 @@ class LocalFile extends File { * @return bool|LocalFile */ static function newFromKey( $sha1, $repo, $timestamp = false ) { - $dbr = $repo->getSlaveDB(); + $dbr = $repo->getReplicaDB(); $conds = [ 'img_sha1' => $sha1 ]; if ( $timestamp ) { @@ -233,77 +237,71 @@ class LocalFile extends File { * @return string|bool */ function getCacheKey() { - $hashedName = md5( $this->getName() ); - - return $this->repo->getSharedCacheKey( 'file', $hashedName ); + return $this->repo->getSharedCacheKey( 'file', sha1( $this->getName() ) ); } /** - * Try to load file metadata from memcached. Returns true on success. - * @return bool + * Try to load file metadata from memcached, falling back to the database */ private function loadFromCache() { $this->dataLoaded = false; $this->extraDataLoaded = false; - $key = $this->getCacheKey(); + $key = $this->getCacheKey(); if ( !$key ) { - return false; - } + $this->loadFromDB( self::READ_NORMAL ); - $cache = ObjectCache::getMainWANInstance(); - $cachedValues = $cache->get( $key ); - - // Check if the key existed and belongs to this version of MediaWiki - if ( is_array( $cachedValues ) && $cachedValues['version'] == MW_FILE_VERSION ) { - $this->fileExists = $cachedValues['fileExists']; - if ( $this->fileExists ) { - $this->setProps( $cachedValues ); - } - $this->dataLoaded = true; - $this->extraDataLoaded = true; - foreach ( $this->getLazyCacheFields( '' ) as $field ) { - $this->extraDataLoaded = $this->extraDataLoaded && isset( $cachedValues[$field] ); - } + return; } - return $this->dataLoaded; - } - - /** - * Save the file metadata to memcached - */ - private function saveToCache() { - $this->load(); + $cache = ObjectCache::getMainWANInstance(); + $cachedValues = $cache->getWithSetCallback( + $key, + $cache::TTL_WEEK, + function ( $oldValue, &$ttl, array &$setOpts ) use ( $cache ) { + $setOpts += Database::getCacheSetOptions( $this->repo->getReplicaDB() ); + + $this->loadFromDB( self::READ_NORMAL ); + + $fields = $this->getCacheFields( '' ); + $cacheVal['fileExists'] = $this->fileExists; + if ( $this->fileExists ) { + foreach ( $fields as $field ) { + $cacheVal[$field] = $this->$field; + } + } + // Strip off excessive entries from the subset of fields that can become large. + // If the cache value gets to large it will not fit in memcached and nothing will + // get cached at all, causing master queries for any file access. + foreach ( $this->getLazyCacheFields( '' ) as $field ) { + if ( isset( $cacheVal[$field] ) + && strlen( $cacheVal[$field] ) > 100 * 1024 + ) { + unset( $cacheVal[$field] ); // don't let the value get too big + } + } - $key = $this->getCacheKey(); - if ( !$key ) { - return; - } + if ( $this->fileExists ) { + $ttl = $cache->adaptiveTTL( wfTimestamp( TS_UNIX, $this->timestamp ), $ttl ); + } else { + $ttl = $cache::TTL_DAY; + } - $fields = $this->getCacheFields( '' ); - $cacheVal = [ 'version' => MW_FILE_VERSION ]; - $cacheVal['fileExists'] = $this->fileExists; + return $cacheVal; + }, + [ 'version' => self::VERSION ] + ); + $this->fileExists = $cachedValues['fileExists']; if ( $this->fileExists ) { - foreach ( $fields as $field ) { - $cacheVal[$field] = $this->$field; - } + $this->setProps( $cachedValues ); } - // Strip off excessive entries from the subset of fields that can become large. - // If the cache value gets to large it will not fit in memcached and nothing will - // get cached at all, causing master queries for any file access. + $this->dataLoaded = true; + $this->extraDataLoaded = true; foreach ( $this->getLazyCacheFields( '' ) as $field ) { - if ( isset( $cacheVal[$field] ) && strlen( $cacheVal[$field] ) > 100 * 1024 ) { - unset( $cacheVal[$field] ); // don't let the value get too big - } + $this->extraDataLoaded = $this->extraDataLoaded && isset( $cachedValues[$field] ); } - - // Cache presence for 1 week and negatives for 1 day - $ttl = $this->fileExists ? 86400 * 7 : 86400; - $opts = Database::getCacheSetOptions( $this->repo->getSlaveDB() ); - ObjectCache::getMainWANInstance()->set( $key, $cacheVal, $ttl, $opts ); } /** @@ -315,9 +313,12 @@ class LocalFile extends File { return; } - $this->repo->getMasterDB()->onTransactionPreCommitOrIdle( function() use ( $key ) { - ObjectCache::getMainWANInstance()->delete( $key ); - } ); + $this->repo->getMasterDB()->onTransactionPreCommitOrIdle( + function () use ( $key ) { + ObjectCache::getMainWANInstance()->delete( $key ); + }, + __METHOD__ + ); } /** @@ -389,7 +390,7 @@ class LocalFile extends File { $dbr = ( $flags & self::READ_LATEST ) ? $this->repo->getMasterDB() - : $this->repo->getSlaveDB(); + : $this->repo->getReplicaDB(); $row = $dbr->selectRow( 'image', $this->getCacheFields( 'img_' ), [ 'img_name' => $this->getName() ], $fname ); @@ -411,7 +412,7 @@ class LocalFile extends File { # Unconditionally set loaded=true, we don't want the accessors constantly rechecking $this->extraDataLoaded = true; - $fieldMap = $this->loadFieldsWithTimestamp( $this->repo->getSlaveDB(), $fname ); + $fieldMap = $this->loadFieldsWithTimestamp( $this->repo->getReplicaDB(), $fname ); if ( !$fieldMap ) { $fieldMap = $this->loadFieldsWithTimestamp( $this->repo->getMasterDB(), $fname ); } @@ -433,16 +434,18 @@ class LocalFile extends File { private function loadFieldsWithTimestamp( $dbr, $fname ) { $fieldMap = false; - $row = $dbr->selectRow( 'image', $this->getLazyCacheFields( 'img_' ), - [ 'img_name' => $this->getName(), 'img_timestamp' => $this->getTimestamp() ], - $fname ); + $row = $dbr->selectRow( 'image', $this->getLazyCacheFields( 'img_' ), [ + 'img_name' => $this->getName(), + 'img_timestamp' => $dbr->timestamp( $this->getTimestamp() ) + ], $fname ); if ( $row ) { $fieldMap = $this->unprefixRow( $row, 'img_' ); } else { # File may have been uploaded over in the meantime; check the old versions - $row = $dbr->selectRow( 'oldimage', $this->getLazyCacheFields( 'oi_' ), - [ 'oi_name' => $this->getName(), 'oi_timestamp' => $this->getTimestamp() ], - $fname ); + $row = $dbr->selectRow( 'oldimage', $this->getLazyCacheFields( 'oi_' ), [ + 'oi_name' => $this->getName(), + 'oi_timestamp' => $dbr->timestamp( $this->getTimestamp() ) + ], $fname ); if ( $row ) { $fieldMap = $this->unprefixRow( $row, 'oi_' ); } @@ -487,7 +490,7 @@ class LocalFile extends File { $decoded['timestamp'] = wfTimestamp( TS_MW, $decoded['timestamp'] ); - $decoded['metadata'] = $this->repo->getSlaveDB()->decodeBlob( $decoded['metadata'] ); + $decoded['metadata'] = $this->repo->getReplicaDB()->decodeBlob( $decoded['metadata'] ); if ( empty( $decoded['major_mime'] ) ) { $decoded['mime'] = 'unknown/unknown'; @@ -538,12 +541,13 @@ class LocalFile extends File { */ function load( $flags = 0 ) { if ( !$this->dataLoaded ) { - if ( ( $flags & self::READ_LATEST ) || !$this->loadFromCache() ) { + if ( $flags & self::READ_LATEST ) { $this->loadFromDB( $flags ); - $this->saveToCache(); + } else { + $this->loadFromCache(); } - $this->dataLoaded = true; } + if ( ( $flags & self::LOAD_ALL ) && !$this->extraDataLoaded ) { // @note: loads on name/timestamp to reduce race condition problems $this->loadExtraFromDB(); @@ -555,37 +559,43 @@ class LocalFile extends File { */ function maybeUpgradeRow() { global $wgUpdateCompatibleMetadata; - if ( wfReadOnly() ) { + + if ( wfReadOnly() || $this->upgrading ) { return; } $upgrade = false; - if ( is_null( $this->media_type ) || - $this->mime == 'image/svg' - ) { + if ( is_null( $this->media_type ) || $this->mime == 'image/svg' ) { $upgrade = true; } else { $handler = $this->getHandler(); if ( $handler ) { $validity = $handler->isMetadataValid( $this, $this->getMetadata() ); - if ( $validity === MediaHandler::METADATA_BAD - || ( $validity === MediaHandler::METADATA_COMPATIBLE && $wgUpdateCompatibleMetadata ) - ) { + if ( $validity === MediaHandler::METADATA_BAD ) { $upgrade = true; + } elseif ( $validity === MediaHandler::METADATA_COMPATIBLE ) { + $upgrade = $wgUpdateCompatibleMetadata; } } } if ( $upgrade ) { - try { - $this->upgradeRow(); - } catch ( LocalFileLockError $e ) { - // let the other process handle it (or do it next time) - } - $this->upgraded = true; // avoid rework/retries + $this->upgrading = true; + // Defer updates unless in auto-commit CLI mode + DeferredUpdates::addCallableUpdate( function() { + $this->upgrading = false; // avoid duplicate updates + try { + $this->upgradeRow(); + } catch ( LocalFileLockError $e ) { + // let the other process handle it (or do it next time) + } + } ); } } + /** + * @return bool Whether upgradeRow() ran for this object + */ function getUpgraded() { return $this->upgraded; } @@ -635,7 +645,7 @@ class LocalFile extends File { $this->invalidateCache(); $this->unlock(); // done - + $this->upgraded = true; // avoid rework/retries } /** @@ -753,7 +763,7 @@ class LocalFile extends File { if ( $type == 'text' ) { return $this->user_text; - } elseif ( $type == 'id' ) { + } else { // id return (int)$this->user; } } @@ -960,6 +970,33 @@ class LocalFile extends File { DeferredUpdates::addUpdate( new CdnCacheUpdate( $urls ), DeferredUpdates::PRESEND ); } + /** + * Prerenders a configurable set of thumbnails + * + * @since 1.28 + */ + public function prerenderThumbnails() { + global $wgUploadThumbnailRenderMap; + + $jobs = []; + + $sizes = $wgUploadThumbnailRenderMap; + rsort( $sizes ); + + foreach ( $sizes as $size ) { + if ( $this->isVectorized() || $this->getWidth() > $size ) { + $jobs[] = new ThumbnailRenderJob( + $this->getTitle(), + [ 'transformParams' => [ 'width' => $size ] ] + ); + } + } + + if ( $jobs ) { + JobQueueGroup::singleton()->lazyPush( $jobs ); + } + } + /** * Delete a list of thumbnails visible at urls * @param string $dir Base dir of the files. @@ -1000,7 +1037,7 @@ class LocalFile extends File { * @return OldLocalFile[] */ function getHistory( $limit = null, $start = null, $end = null, $inc = true ) { - $dbr = $this->repo->getSlaveDB(); + $dbr = $this->repo->getReplicaDB(); $tables = [ 'oldimage' ]; $fields = OldLocalFile::selectFields(); $conds = $opts = $join_conds = []; @@ -1054,7 +1091,7 @@ class LocalFile extends File { # Polymorphic function name to distinguish foreign and local fetches $fname = get_class( $this ) . '::' . __FUNCTION__; - $dbr = $this->repo->getSlaveDB(); + $dbr = $this->repo->getReplicaDB(); if ( $this->historyLine == 0 ) { // called for the first time, return line from cur $this->historyRes = $dbr->select( 'image', @@ -1123,7 +1160,7 @@ class LocalFile extends File { * @param User|null $user User object or null to use $wgUser * @param string[] $tags Change tags to add to the log entry and page revision. * (This doesn't check $user's permissions.) - * @return FileRepoStatus On success, the value member contains the + * @return Status On success, the value member contains the * archive name, or an empty string if it was a new file. */ function upload( $src, $comment, $pageText, $flags = 0, $props = false, @@ -1142,7 +1179,8 @@ class LocalFile extends File { ) { $props = $this->repo->getFileProps( $srcPath ); } else { - $props = FSFile::getPropsFromPath( $srcPath ); + $mwProps = new MWFileProps( MimeMagic::singleton() ); + $props = $mwProps->getPropsFromPath( $srcPath, true ); } } @@ -1418,97 +1456,108 @@ class LocalFile extends File { # Do some cache purges after final commit so that: # a) Changes are more likely to be seen post-purge # b) They won't cause rollback of the log publish/update above - $that = $this; - $dbw->onTransactionIdle( function () use ( - $that, $reupload, $wikiPage, $newPageContent, $comment, $user, $logEntry, $logId, $descId, $tags - ) { - # Update memcache after the commit - $that->invalidateCache(); - - $updateLogPage = false; - if ( $newPageContent ) { - # New file page; create the description page. - # There's already a log entry, so don't make a second RC entry - # CDN and file cache for the description page are purged by doEditContent. - $status = $wikiPage->doEditContent( - $newPageContent, - $comment, - EDIT_NEW | EDIT_SUPPRESS_RC, - false, - $user - ); - - if ( isset( $status->value['revision'] ) ) { - // Associate new page revision id - $logEntry->setAssociatedRevId( $status->value['revision']->getId() ); - } - // This relies on the resetArticleID() call in WikiPage::insertOn(), - // which is triggered on $descTitle by doEditContent() above. - if ( isset( $status->value['revision'] ) ) { - /** @var $rev Revision */ - $rev = $status->value['revision']; - $updateLogPage = $rev->getPage(); - } - } else { - # Existing file page: invalidate description page cache - $wikiPage->getTitle()->invalidateCache(); - $wikiPage->getTitle()->purgeSquid(); - # Allow the new file version to be patrolled from the page footer - Article::purgePatrolFooterCache( $descId ); - } + DeferredUpdates::addUpdate( + new AutoCommitUpdate( + $dbw, + __METHOD__, + function () use ( + $reupload, $wikiPage, $newPageContent, $comment, $user, + $logEntry, $logId, $descId, $tags + ) { + # Update memcache after the commit + $this->invalidateCache(); + + $updateLogPage = false; + if ( $newPageContent ) { + # New file page; create the description page. + # There's already a log entry, so don't make a second RC entry + # CDN and file cache for the description page are purged by doEditContent. + $status = $wikiPage->doEditContent( + $newPageContent, + $comment, + EDIT_NEW | EDIT_SUPPRESS_RC, + false, + $user + ); + + if ( isset( $status->value['revision'] ) ) { + /** @var $rev Revision */ + $rev = $status->value['revision']; + // Associate new page revision id + $logEntry->setAssociatedRevId( $rev->getId() ); + } + // This relies on the resetArticleID() call in WikiPage::insertOn(), + // which is triggered on $descTitle by doEditContent() above. + if ( isset( $status->value['revision'] ) ) { + /** @var $rev Revision */ + $rev = $status->value['revision']; + $updateLogPage = $rev->getPage(); + } + } else { + # Existing file page: invalidate description page cache + $wikiPage->getTitle()->invalidateCache(); + $wikiPage->getTitle()->purgeSquid(); + # Allow the new file version to be patrolled from the page footer + Article::purgePatrolFooterCache( $descId ); + } - # Update associated rev id. This should be done by $logEntry->insert() earlier, - # but setAssociatedRevId() wasn't called at that point yet... - $logParams = $logEntry->getParameters(); - $logParams['associated_rev_id'] = $logEntry->getAssociatedRevId(); - $update = [ 'log_params' => LogEntryBase::makeParamBlob( $logParams ) ]; - if ( $updateLogPage ) { - # Also log page, in case where we just created it above - $update['log_page'] = $updateLogPage; - } - $that->getRepo()->getMasterDB()->update( - 'logging', - $update, - [ 'log_id' => $logId ], - __METHOD__ - ); - $that->getRepo()->getMasterDB()->insert( - 'log_search', - [ - 'ls_field' => 'associated_rev_id', - 'ls_value' => $logEntry->getAssociatedRevId(), - 'ls_log_id' => $logId, - ], - __METHOD__ - ); + # Update associated rev id. This should be done by $logEntry->insert() earlier, + # but setAssociatedRevId() wasn't called at that point yet... + $logParams = $logEntry->getParameters(); + $logParams['associated_rev_id'] = $logEntry->getAssociatedRevId(); + $update = [ 'log_params' => LogEntryBase::makeParamBlob( $logParams ) ]; + if ( $updateLogPage ) { + # Also log page, in case where we just created it above + $update['log_page'] = $updateLogPage; + } + $this->getRepo()->getMasterDB()->update( + 'logging', + $update, + [ 'log_id' => $logId ], + __METHOD__ + ); + $this->getRepo()->getMasterDB()->insert( + 'log_search', + [ + 'ls_field' => 'associated_rev_id', + 'ls_value' => $logEntry->getAssociatedRevId(), + 'ls_log_id' => $logId, + ], + __METHOD__ + ); + + # Add change tags, if any + if ( $tags ) { + $logEntry->setTags( $tags ); + } - # Add change tags, if any - if ( $tags ) { - $logEntry->setTags( $tags ); - } + # Uploads can be patrolled + $logEntry->setIsPatrollable( true ); - # Uploads can be patrolled - $logEntry->setIsPatrollable( true ); + # Now that the log entry is up-to-date, make an RC entry. + $logEntry->publish( $logId ); - # Now that the log entry is up-to-date, make an RC entry. - $logEntry->publish( $logId ); + # Run hook for other updates (typically more cache purging) + Hooks::run( 'FileUpload', [ $this, $reupload, !$newPageContent ] ); - # Run hook for other updates (typically more cache purging) - Hooks::run( 'FileUpload', [ $that, $reupload, !$newPageContent ] ); + if ( $reupload ) { + # Delete old thumbnails + $this->purgeThumbnails(); + # Remove the old file from the CDN cache + DeferredUpdates::addUpdate( + new CdnCacheUpdate( [ $this->getUrl() ] ), + DeferredUpdates::PRESEND + ); + } else { + # Update backlink pages pointing to this title if created + LinksUpdate::queueRecursiveJobsForTable( $this->getTitle(), 'imagelinks' ); + } - if ( $reupload ) { - # Delete old thumbnails - $that->purgeThumbnails(); - # Remove the old file from the CDN cache - DeferredUpdates::addUpdate( - new CdnCacheUpdate( [ $that->getUrl() ] ), - DeferredUpdates::PRESEND - ); - } else { - # Update backlink pages pointing to this title if created - LinksUpdate::queueRecursiveJobsForTable( $that->getTitle(), 'imagelinks' ); - } - } ); + $this->prerenderThumbnails(); + } + ), + DeferredUpdates::PRESEND + ); if ( !$reupload ) { # This is a new file, so update the image count @@ -1533,7 +1582,7 @@ class LocalFile extends File { * @param int $flags A bitwise combination of: * File::DELETE_SOURCE Delete the source file, i.e. move rather than copy * @param array $options Optional additional parameters - * @return FileRepoStatus On success, the value member contains the + * @return Status On success, the value member contains the * archive name, or an empty string if it was a new file. */ function publish( $src, $flags = 0, array $options = [] ) { @@ -1552,7 +1601,7 @@ class LocalFile extends File { * @param int $flags A bitwise combination of: * File::DELETE_SOURCE Delete the source file, i.e. move rather than copy * @param array $options Optional additional parameters - * @return FileRepoStatus On success, the value member contains the + * @return Status On success, the value member contains the * archive name, or an empty string if it was a new file. */ function publishTo( $src, $dstRel, $flags = 0, array $options = [] ) { @@ -1572,7 +1621,9 @@ class LocalFile extends File { $sha1 = $repo->isVirtualUrl( $srcPath ) ? $repo->getFileSha1( $srcPath ) : FSFile::getSha1Base36FromPath( $srcPath ); - $dst = $repo->getBackend()->getPathForSHA1( $sha1 ); + /** @var FileBackendDBRepoWrapper $wrapperBackend */ + $wrapperBackend = $repo->getBackend(); + $dst = $wrapperBackend->getPathForSHA1( $sha1 ); $status = $repo->quickImport( $src, $dst ); if ( $flags & File::DELETE_SOURCE ) { unlink( $srcPath ); @@ -1612,7 +1663,7 @@ class LocalFile extends File { * and logging are caller's responsibility * * @param Title $target New file name - * @return FileRepoStatus + * @return Status */ function move( $target ) { if ( $this->getRepo()->getReadOnlyReason() !== false ) { @@ -1633,16 +1684,20 @@ class LocalFile extends File { // Purge the source and target files... $oldTitleFile = wfLocalFile( $this->title ); $newTitleFile = wfLocalFile( $target ); - // Hack: the lock()/unlock() pair is nested in a transaction so the locking is not - // tied to BEGIN/COMMIT. To avoid slow purges in the transaction, move them outside. - $this->getRepo()->getMasterDB()->onTransactionIdle( - function () use ( $oldTitleFile, $newTitleFile, $archiveNames ) { - $oldTitleFile->purgeEverything(); - foreach ( $archiveNames as $archiveName ) { - $oldTitleFile->purgeOldThumbnails( $archiveName ); + // To avoid slow purges in the transaction, move them outside... + DeferredUpdates::addUpdate( + new AutoCommitUpdate( + $this->getRepo()->getMasterDB(), + __METHOD__, + function () use ( $oldTitleFile, $newTitleFile, $archiveNames ) { + $oldTitleFile->purgeEverything(); + foreach ( $archiveNames as $archiveName ) { + $oldTitleFile->purgeOldThumbnails( $archiveName ); + } + $newTitleFile->purgeEverything(); } - $newTitleFile->purgeEverything(); - } + ), + DeferredUpdates::PRESEND ); if ( $status->isOK() ) { @@ -1667,7 +1722,7 @@ class LocalFile extends File { * @param string $reason * @param bool $suppress * @param User|null $user - * @return FileRepoStatus + * @return Status */ function delete( $reason, $suppress = false, $user = null ) { if ( $this->getRepo()->getReadOnlyReason() !== false ) { @@ -1678,7 +1733,7 @@ class LocalFile extends File { $this->lock(); // begin $batch->addCurrent(); - # Get old version relative paths + // Get old version relative paths $archiveNames = $batch->addOlds(); $status = $batch->execute(); $this->unlock(); // done @@ -1687,16 +1742,19 @@ class LocalFile extends File { DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [ 'images' => -1 ] ) ); } - // Hack: the lock()/unlock() pair is nested in a transaction so the locking is not - // tied to BEGIN/COMMIT. To avoid slow purges in the transaction, move them outside. - $that = $this; - $this->getRepo()->getMasterDB()->onTransactionIdle( - function () use ( $that, $archiveNames ) { - $that->purgeEverything(); - foreach ( $archiveNames as $archiveName ) { - $that->purgeOldThumbnails( $archiveName ); + // To avoid slow purges in the transaction, move them outside... + DeferredUpdates::addUpdate( + new AutoCommitUpdate( + $this->getRepo()->getMasterDB(), + __METHOD__, + function () use ( $archiveNames ) { + $this->purgeEverything(); + foreach ( $archiveNames as $archiveName ) { + $this->purgeOldThumbnails( $archiveName ); + } } - } + ), + DeferredUpdates::PRESEND ); // Purge the CDN @@ -1722,7 +1780,7 @@ class LocalFile extends File { * @param bool $suppress * @param User|null $user * @throws MWException Exception on database or file store failure - * @return FileRepoStatus + * @return Status */ function deleteOld( $archiveName, $reason, $suppress = false, $user = null ) { if ( $this->getRepo()->getReadOnlyReason() !== false ) { @@ -1758,7 +1816,7 @@ class LocalFile extends File { * @param array $versions Set of record ids of deleted items to restore, * or empty to restore all revisions. * @param bool $unsuppress - * @return FileRepoStatus + * @return Status */ function restore( $versions = [], $unsuppress = false ) { if ( $this->getRepo()->getReadOnlyReason() !== false ) { @@ -1859,7 +1917,7 @@ class LocalFile extends File { 'page_namespace' => $this->title->getNamespace(), 'page_title' => $this->title->getDBkey() ]; - $touched = $this->repo->getSlaveDB()->selectField( 'page', 'page_touched', $cond, __METHOD__ ); + $touched = $this->repo->getReplicaDB()->selectField( 'page', 'page_touched', $cond, __METHOD__ ); $this->descriptionTouched = $touched ? wfTimestamp( TS_MW, $touched ) : false; } @@ -1903,65 +1961,90 @@ class LocalFile extends File { } /** - * Start a transaction and lock the image for update - * Increments a reference counter if the lock is already held + * @return Status + * @since 1.28 + */ + public function acquireFileLock() { + return $this->getRepo()->getBackend()->lockFiles( + [ $this->getPath() ], LockManager::LOCK_EX, 10 + ); + } + + /** + * @return Status + * @since 1.28 + */ + public function releaseFileLock() { + return $this->getRepo()->getBackend()->unlockFiles( + [ $this->getPath() ], LockManager::LOCK_EX + ); + } + + /** + * Start an atomic DB section and lock the image for update + * or increments a reference counter if the lock is already held + * + * This method should not be used outside of LocalFile/LocalFile*Batch + * * @throws LocalFileLockError Throws an error if the lock was not acquired * @return bool Whether the file lock owns/spawned the DB transaction */ - function lock() { + public function lock() { if ( !$this->locked ) { + $logger = LoggerFactory::getInstance( 'LocalFile' ); + $dbw = $this->repo->getMasterDB(); - if ( !$dbw->trxLevel() ) { - $dbw->begin( __METHOD__ ); - $this->lockedOwnTrx = true; - } + $makesTransaction = !$dbw->trxLevel(); + $dbw->startAtomic( self::ATOMIC_SECTION_LOCK ); // Bug 54736: use simple lock to handle when the file does not exist. // SELECT FOR UPDATE prevents changes, not other SELECTs with FOR UPDATE. // Also, that would cause contention on INSERT of similarly named rows. - $backend = $this->getRepo()->getBackend(); - $lockPaths = [ $this->getPath() ]; // represents all versions of the file - $status = $backend->lockFiles( $lockPaths, LockManager::LOCK_EX, 5 ); + $status = $this->acquireFileLock(); // represents all versions of the file if ( !$status->isGood() ) { - if ( $this->lockedOwnTrx ) { - $dbw->rollback( __METHOD__ ); - } - throw new LocalFileLockError( "Could not acquire lock for '{$this->getName()}.'" ); + $dbw->endAtomic( self::ATOMIC_SECTION_LOCK ); + $logger->warning( "Failed to lock '{file}'", [ 'file' => $this->name ] ); + + throw new LocalFileLockError( $status ); } - // Release the lock *after* commit to avoid row-level contention - $this->locked++; - $dbw->onTransactionIdle( function () use ( $backend, $lockPaths ) { - $backend->unlockFiles( $lockPaths, LockManager::LOCK_EX ); - } ); + // Release the lock *after* commit to avoid row-level contention. + // Make sure it triggers on rollback() as well as commit() (T132921). + $dbw->onTransactionResolution( + function () use ( $logger ) { + $status = $this->releaseFileLock(); + if ( !$status->isGood() ) { + $logger->error( "Failed to unlock '{file}'", [ 'file' => $this->name ] ); + } + }, + __METHOD__ + ); + // Callers might care if the SELECT snapshot is safely fresh + $this->lockedOwnTrx = $makesTransaction; } + $this->locked++; + return $this->lockedOwnTrx; } /** - * Decrement the lock reference count. If the reference count is reduced to zero, commits - * the transaction and thereby releases the image lock. + * Decrement the lock reference count and end the atomic section if it reaches zero + * + * This method should not be used outside of LocalFile/LocalFile*Batch + * + * The commit and loc release will happen when no atomic sections are active, which + * may happen immediately or at some point after calling this */ - function unlock() { + public function unlock() { if ( $this->locked ) { --$this->locked; - if ( !$this->locked && $this->lockedOwnTrx ) { + if ( !$this->locked ) { $dbw = $this->repo->getMasterDB(); - $dbw->commit( __METHOD__ ); + $dbw->endAtomic( self::ATOMIC_SECTION_LOCK ); $this->lockedOwnTrx = false; } } } - /** - * Roll back the DB transaction and mark the image unlocked - */ - function unlockAndRollback() { - $this->locked = false; - $dbw = $this->repo->getMasterDB(); - $dbw->rollback( __METHOD__ ); - $this->lockedOwnTrx = false; - } - /** * @return Status */ @@ -2138,8 +2221,9 @@ class LocalFileDeleteBatch { } protected function doDBInserts() { + $now = time(); $dbw = $this->file->repo->getMasterDB(); - $encTimestamp = $dbw->addQuotes( $dbw->timestamp() ); + $encTimestamp = $dbw->addQuotes( $dbw->timestamp( $now ) ); $encUserId = $dbw->addQuotes( $this->user->getId() ); $encReason = $dbw->addQuotes( $this->reason ); $encGroup = $dbw->addQuotes( 'deleted' ); @@ -2150,32 +2234,26 @@ class LocalFileDeleteBatch { // Bitfields to further suppress the content if ( $this->suppress ) { - $bitfield = 0; - // This should be 15... - $bitfield |= Revision::DELETED_TEXT; - $bitfield |= Revision::DELETED_COMMENT; - $bitfield |= Revision::DELETED_USER; - $bitfield |= Revision::DELETED_RESTRICTED; + $bitfield = Revision::SUPPRESSED_ALL; } else { $bitfield = 'oi_deleted'; } if ( $deleteCurrent ) { - $concat = $dbw->buildConcat( [ "img_sha1", $encExt ] ); - $where = [ 'img_name' => $this->file->getName() ]; - $dbw->insertSelect( 'filearchive', 'image', + $dbw->insertSelect( + 'filearchive', + 'image', [ 'fa_storage_group' => $encGroup, 'fa_storage_key' => $dbw->conditional( [ 'img_sha1' => '' ], $dbw->addQuotes( '' ), - $concat + $dbw->buildConcat( [ "img_sha1", $encExt ] ) ), 'fa_deleted_user' => $encUserId, 'fa_deleted_timestamp' => $encTimestamp, 'fa_deleted_reason' => $encReason, 'fa_deleted' => $this->suppress ? $bitfield : 0, - 'fa_name' => 'img_name', 'fa_archive_name' => 'NULL', 'fa_size' => 'img_size', @@ -2190,44 +2268,56 @@ class LocalFileDeleteBatch { 'fa_user' => 'img_user', 'fa_user_text' => 'img_user_text', 'fa_timestamp' => 'img_timestamp', - 'fa_sha1' => 'img_sha1', - ], $where, __METHOD__ ); + 'fa_sha1' => 'img_sha1' + ], + [ 'img_name' => $this->file->getName() ], + __METHOD__ + ); } if ( count( $oldRels ) ) { - $concat = $dbw->buildConcat( [ "oi_sha1", $encExt ] ); - $where = [ - 'oi_name' => $this->file->getName(), - 'oi_archive_name' => array_keys( $oldRels ) ]; - $dbw->insertSelect( 'filearchive', 'oldimage', + $res = $dbw->select( + 'oldimage', + OldLocalFile::selectFields(), [ - 'fa_storage_group' => $encGroup, - 'fa_storage_key' => $dbw->conditional( - [ 'oi_sha1' => '' ], - $dbw->addQuotes( '' ), - $concat - ), - 'fa_deleted_user' => $encUserId, - 'fa_deleted_timestamp' => $encTimestamp, - 'fa_deleted_reason' => $encReason, - 'fa_deleted' => $this->suppress ? $bitfield : 'oi_deleted', - - 'fa_name' => 'oi_name', - 'fa_archive_name' => 'oi_archive_name', - 'fa_size' => 'oi_size', - 'fa_width' => 'oi_width', - 'fa_height' => 'oi_height', - 'fa_metadata' => 'oi_metadata', - 'fa_bits' => 'oi_bits', - 'fa_media_type' => 'oi_media_type', - 'fa_major_mime' => 'oi_major_mime', - 'fa_minor_mime' => 'oi_minor_mime', - 'fa_description' => 'oi_description', - 'fa_user' => 'oi_user', - 'fa_user_text' => 'oi_user_text', - 'fa_timestamp' => 'oi_timestamp', - 'fa_sha1' => 'oi_sha1', - ], $where, __METHOD__ ); + 'oi_name' => $this->file->getName(), + 'oi_archive_name' => array_keys( $oldRels ) + ], + __METHOD__, + [ 'FOR UPDATE' ] + ); + $rowsInsert = []; + foreach ( $res as $row ) { + $rowsInsert[] = [ + // Deletion-specific fields + 'fa_storage_group' => 'deleted', + 'fa_storage_key' => ( $row->oi_sha1 === '' ) + ? '' + : "{$row->oi_sha1}{$dotExt}", + 'fa_deleted_user' => $this->user->getId(), + 'fa_deleted_timestamp' => $dbw->timestamp( $now ), + 'fa_deleted_reason' => $this->reason, + // Counterpart fields + 'fa_deleted' => $this->suppress ? $bitfield : $row->oi_deleted, + 'fa_name' => $row->oi_name, + 'fa_archive_name' => $row->oi_archive_name, + 'fa_size' => $row->oi_size, + 'fa_width' => $row->oi_width, + 'fa_height' => $row->oi_height, + 'fa_metadata' => $row->oi_metadata, + 'fa_bits' => $row->oi_bits, + 'fa_media_type' => $row->oi_media_type, + 'fa_major_mime' => $row->oi_major_mime, + 'fa_minor_mime' => $row->oi_minor_mime, + 'fa_description' => $row->oi_description, + 'fa_user' => $row->oi_user, + 'fa_user_text' => $row->oi_user_text, + 'fa_timestamp' => $row->oi_timestamp, + 'fa_sha1' => $row->oi_sha1 + ]; + } + + $dbw->insert( 'filearchive', $rowsInsert, __METHOD__ ); } } @@ -2250,7 +2340,7 @@ class LocalFileDeleteBatch { /** * Run the transaction - * @return FileRepoStatus + * @return Status */ public function execute() { $repo = $this->file->getRepo(); @@ -2272,13 +2362,6 @@ class LocalFileDeleteBatch { } } - // Lock the filearchive rows so that the files don't get deleted by a cleanup operation - // We acquire this lock by running the inserts now, before the file operations. - // This potentially has poor lock contention characteristics -- an alternative - // scheme would be to insert stub filearchive entries with no fa_name and commit - // them in a separate transaction, then run the file ops, then update the fa_name fields. - $this->doDBInserts(); - if ( !$repo->hasSha1Storage() ) { // Removes non-existent file from the batch, so we don't get errors. // This also handles files in the 'deleted' zone deleted via revision deletion. @@ -2291,21 +2374,20 @@ class LocalFileDeleteBatch { // Execute the file deletion batch $status = $this->file->repo->deleteBatch( $this->deletionBatch ); - if ( !$status->isGood() ) { $this->status->merge( $status ); } } if ( !$this->status->isOK() ) { - // Critical file deletion error - // Roll back inserts, release lock and abort - // TODO: delete the defunct filearchive rows if we are using a non-transactional DB - $this->file->unlockAndRollback(); + // Critical file deletion error; abort + $this->file->unlock(); return $this->status; } + // Copy the image/oldimage rows to filearchive + $this->doDBInserts(); // Delete image/oldimage rows $this->doDBDeletes(); @@ -2406,9 +2488,10 @@ class LocalFileRestoreBatch { * rows and there's no need to keep the image row locked while it's acquiring those locks * The caller may have its own transaction open. * So we save the batch and let the caller call cleanup() - * @return FileRepoStatus + * @return Status */ public function execute() { + /** @var Language */ global $wgLang; $repo = $this->file->getRepo(); @@ -2526,8 +2609,9 @@ class LocalFileRestoreBatch { // The live (current) version cannot be hidden! if ( !$this->unsuppress && $row->fa_deleted ) { - $storeBatch[] = [ $deletedUrl, 'public', $destRel ]; - $this->cleanupBatch[] = $row->fa_storage_key; + $status->fatal( 'undeleterevdel' ); + $this->file->unlock(); + return $status; } } else { $archiveName = $row->fa_archive_name; @@ -2605,7 +2689,7 @@ class LocalFileRestoreBatch { // Even if some files could be copied, fail entirely as that is the // easiest thing to do without data loss $this->cleanupFailedBatch( $storeStatus, $storeBatch ); - $status->ok = false; + $status->setOK( false ); $this->file->unlock(); return $status; @@ -2705,7 +2789,7 @@ class LocalFileRestoreBatch { /** * Delete unused files in the deleted zone. * This should be called from outside the transaction in which execute() was called. - * @return FileRepoStatus + * @return Status */ public function cleanup() { if ( !$this->cleanupBatch ) { @@ -2762,7 +2846,7 @@ class LocalFileMoveBatch { protected $archive; - /** @var DatabaseBase */ + /** @var IDatabase */ protected $db; /** @@ -2840,38 +2924,35 @@ class LocalFileMoveBatch { /** * Perform the move. - * @return FileRepoStatus + * @return Status */ public function execute() { $repo = $this->file->repo; $status = $repo->newGood(); + $destFile = wfLocalFile( $this->target ); + + $this->file->lock(); // begin + $destFile->lock(); // quickly fail if destination is not available $triplets = $this->getMoveTriplets(); $checkStatus = $this->removeNonexistentFiles( $triplets ); if ( !$checkStatus->isGood() ) { - $status->merge( $checkStatus ); + $destFile->unlock(); + $this->file->unlock(); + $status->merge( $checkStatus ); // couldn't talk to file backend return $status; } $triplets = $checkStatus->value; - $destFile = wfLocalFile( $this->target ); - $this->file->lock(); // begin - $destFile->lock(); // quickly fail if destination is not available - // Rename the file versions metadata in the DB. - // This implicitly locks the destination file, which avoids race conditions. - // If we moved the files from A -> C before DB updates, another process could - // move files from B -> C at this point, causing storeBatch() to fail and thus - // cleanupTarget() to trigger. It would delete the C files and cause data loss. - $statusDb = $this->doDBUpdates(); + // Verify the file versions metadata in the DB. + $statusDb = $this->verifyDBUpdates(); if ( !$statusDb->isGood() ) { $destFile->unlock(); - $this->file->unlockAndRollback(); - $statusDb->ok = false; + $this->file->unlock(); + $statusDb->setOK( false ); return $statusDb; } - wfDebugLog( 'imagemove', "Renamed {$this->file->getName()} in database: " . - "{$statusDb->successCount} successes, {$statusDb->failCount} failures" ); if ( !$repo->hasSha1Storage() ) { // Copy the files into their new location. @@ -2884,16 +2965,22 @@ class LocalFileMoveBatch { // Delete any files copied over (while the destination is still locked) $this->cleanupTarget( $triplets ); $destFile->unlock(); - $this->file->unlockAndRollback(); // unlocks the destination + $this->file->unlock(); wfDebugLog( 'imagemove', "Error in moving files: " . $statusMove->getWikiText( false, false, 'en' ) ); - $statusMove->ok = false; + $statusMove->setOK( false ); return $statusMove; } $status->merge( $statusMove ); } + // Rename the file versions metadata in the DB. + $this->doDBUpdates(); + + wfDebugLog( 'imagemove', "Renamed {$this->file->getName()} in database: " . + "{$statusDb->successCount} successes, {$statusDb->failCount} failures" ); + $destFile->unlock(); $this->file->unlock(); // done @@ -2906,33 +2993,62 @@ class LocalFileMoveBatch { } /** - * Do the database updates and return a new FileRepoStatus indicating how - * many rows where updated. + * Verify the database updates and return a new FileRepoStatus indicating how + * many rows would be updated. * - * @return FileRepoStatus + * @return Status */ - protected function doDBUpdates() { + protected function verifyDBUpdates() { $repo = $this->file->repo; $status = $repo->newGood(); $dbw = $this->db; - // Update current image - $dbw->update( + $hasCurrent = $dbw->selectField( 'image', - [ 'img_name' => $this->newName ], + '1', [ 'img_name' => $this->oldName ], - __METHOD__ + __METHOD__, + [ 'FOR UPDATE' ] + ); + $oldRowCount = $dbw->selectField( + 'oldimage', + 'COUNT(*)', + [ 'oi_name' => $this->oldName ], + __METHOD__, + [ 'FOR UPDATE' ] ); - if ( $dbw->affectedRows() ) { + if ( $hasCurrent ) { $status->successCount++; } else { $status->failCount++; - $status->fatal( 'imageinvalidfilename' ); - - return $status; } + $status->successCount += $oldRowCount; + // Bug 34934: oldCount is based on files that actually exist. + // There may be more DB rows than such files, in which case $affected + // can be greater than $total. We use max() to avoid negatives here. + $status->failCount += max( 0, $this->oldCount - $oldRowCount ); + if ( $status->failCount ) { + $status->error( 'imageinvalidfilename' ); + } + + return $status; + } + /** + * Do the database updates and return a new FileRepoStatus indicating how + * many rows where updated. + */ + protected function doDBUpdates() { + $dbw = $this->db; + + // Update current image + $dbw->update( + 'image', + [ 'img_name' => $this->newName ], + [ 'img_name' => $this->oldName ], + __METHOD__ + ); // Update old images $dbw->update( 'oldimage', @@ -2944,19 +3060,6 @@ class LocalFileMoveBatch { [ 'oi_name' => $this->oldName ], __METHOD__ ); - - $affected = $dbw->affectedRows(); - $total = $this->oldCount; - $status->successCount += $affected; - // Bug 34934: $total is based on files that actually exist. - // There may be more DB rows than such files, in which case $affected - // can be greater than $total. We use max() to avoid negatives here. - $status->failCount += max( 0, $total - $affected ); - if ( $status->failCount ) { - $status->error( 'imageinvalidfilename' ); - } - - return $status; } /** @@ -3042,6 +3145,17 @@ class LocalFileMoveBatch { } } -class LocalFileLockError extends Exception { +class LocalFileLockError extends ErrorPageError { + public function __construct( Status $status ) { + parent::__construct( + 'actionfailed', + $status->getMessage() + ); + } + public function report() { + global $wgOut; + $wgOut->setStatusCode( 429 ); + parent::report(); + } } diff --git a/includes/filerepo/file/OldLocalFile.php b/includes/filerepo/file/OldLocalFile.php index 31e62ecbe9..dfaae731c1 100644 --- a/includes/filerepo/file/OldLocalFile.php +++ b/includes/filerepo/file/OldLocalFile.php @@ -86,7 +86,7 @@ class OldLocalFile extends LocalFile { * @return bool|OldLocalFile */ static function newFromKey( $sha1, $repo, $timestamp = false ) { - $dbr = $repo->getSlaveDB(); + $dbr = $repo->getReplicaDB(); $conds = [ 'oi_sha1' => $sha1 ]; if ( $timestamp ) { @@ -179,7 +179,7 @@ class OldLocalFile extends LocalFile { $dbr = ( $flags & self::READ_LATEST ) ? $this->repo->getMasterDB() - : $this->repo->getSlaveDB(); + : $this->repo->getReplicaDB(); $conds = [ 'oi_name' => $this->getName() ]; if ( is_null( $this->requestedTime ) ) { @@ -194,16 +194,14 @@ class OldLocalFile extends LocalFile { } else { $this->fileExists = false; } - } /** * Load lazy file metadata from the DB */ protected function loadExtraFromDB() { - $this->extraDataLoaded = true; - $dbr = $this->repo->getSlaveDB(); + $dbr = $this->repo->getReplicaDB(); $conds = [ 'oi_name' => $this->getName() ]; if ( is_null( $this->requestedTime ) ) { $conds['oi_archive_name'] = $this->archive_name; @@ -227,7 +225,6 @@ class OldLocalFile extends LocalFile { } else { throw new MWException( "Could not find data for image '{$this->archive_name}'." ); } - } /** @@ -332,7 +329,7 @@ class OldLocalFile extends LocalFile { * @param string $timestamp * @param string $comment * @param User $user - * @return FileRepoStatus + * @return Status */ function uploadOld( $srcPath, $archiveName, $timestamp, $comment, $user ) { $this->lock(); diff --git a/includes/gallery/ImageGalleryBase.php b/includes/gallery/ImageGalleryBase.php index c8a25f0b28..6884f65626 100644 --- a/includes/gallery/ImageGalleryBase.php +++ b/includes/gallery/ImageGalleryBase.php @@ -113,6 +113,7 @@ abstract class ImageGalleryBase extends ContextSource { 'packed' => 'PackedImageGallery', 'packed-hover' => 'PackedHoverImageGallery', 'packed-overlay' => 'PackedOverlayImageGallery', + 'slideshow' => 'SlideshowImageGallery', ]; // Allow extensions to make a new gallery format. Hooks::run( 'GalleryGetModes', [ &self::$modeMapping ] ); diff --git a/includes/gallery/SlideshowImageGallery.php b/includes/gallery/SlideshowImageGallery.php new file mode 100644 index 0000000000..3f0c9329d0 --- /dev/null +++ b/includes/gallery/SlideshowImageGallery.php @@ -0,0 +1,37 @@ +mPerRow = 0; + } + + /** + * Add javascript adds interface elements + * @return array + */ + protected function getModules() { + return [ 'mediawiki.page.gallery.slideshow' ]; + } +} diff --git a/includes/gallery/TraditionalImageGallery.php b/includes/gallery/TraditionalImageGallery.php index f00e260e7b..0f889da683 100644 --- a/includes/gallery/TraditionalImageGallery.php +++ b/includes/gallery/TraditionalImageGallery.php @@ -59,6 +59,16 @@ class TraditionalImageGallery extends ImageGalleryBase { $output .= "\n\t
  • {$this->mCaption}
  • "; } + if ( $this->mShowFilename ) { + // Preload LinkCache info for when generating links + // of the filename below + $lb = new LinkBatch(); + foreach ( $this->mImages as $img ) { + $lb->addObj( $img[0] ); + } + $lb->execute(); + } + $lang = $this->getRenderLang(); # Output each image... foreach ( $this->mImages as $pair ) { @@ -176,10 +186,19 @@ class TraditionalImageGallery extends ImageGalleryBase { } $textlink = $this->mShowFilename ? + // Preloaded into LinkCache above Linker::linkKnown( $nt, - htmlspecialchars( $lang->truncate( $nt->getText(), $this->mCaptionLength ) ) - ) . "
    \n" : + htmlspecialchars( + $this->mCaptionLength !== true ? + $lang->truncate( $nt->getText(), $this->mCaptionLength ) : + $nt->getText() + ), + [ + 'class' => 'galleryfilename' . + ( $this->mCaptionLength === true ? ' galleryfilename-truncate' : '' ) + ] + ) . "\n" : ''; $galleryText = $textlink . $text . $fileSize; @@ -219,8 +238,8 @@ class TraditionalImageGallery extends ImageGalleryBase { } /** - * How much padding such the thumb have between image and inner div that - * that contains the border. This is both for verical and horizontal + * How much padding the thumb has between the image and the inner div + * that contains the border. This is for both vertical and horizontal * padding. (However, it is cut in half in the vertical direction). * @return int */ diff --git a/includes/htmlform/HTMLApiField.php b/includes/htmlform/HTMLApiField.php deleted file mode 100644 index 24a253eddd..0000000000 --- a/includes/htmlform/HTMLApiField.php +++ /dev/null @@ -1,23 +0,0 @@ -getTableRow( $value ); - } - - public function getRaw( $value ) { - return $this->getTableRow( $value ); - } - - public function getInputHTML( $value ) { - return ''; - } - - public function hasVisibleOutput() { - return false; - } -} diff --git a/includes/htmlform/HTMLAutoCompleteSelectField.php b/includes/htmlform/HTMLAutoCompleteSelectField.php deleted file mode 100644 index 76a88d5121..0000000000 --- a/includes/htmlform/HTMLAutoCompleteSelectField.php +++ /dev/null @@ -1,177 +0,0 @@ - false, - ]; - - parent::__construct( $params ); - - if ( array_key_exists( 'autocomplete-messages', $this->mParams ) ) { - foreach ( $this->mParams['autocomplete-messages'] as $key => $value ) { - $key = $this->msg( $key )->plain(); - $this->autocomplete[$key] = strval( $value ); - } - } elseif ( array_key_exists( 'autocomplete', $this->mParams ) ) { - foreach ( $this->mParams['autocomplete'] as $key => $value ) { - $this->autocomplete[$key] = strval( $value ); - } - } - if ( !is_array( $this->autocomplete ) || !$this->autocomplete ) { - throw new MWException( 'HTMLAutoCompleteSelectField called without any autocompletions' ); - } - - $this->getOptions(); - if ( $this->mOptions && !in_array( 'other', $this->mOptions, true ) ) { - if ( isset( $params['other-message'] ) ) { - $msg = $this->getMessage( $params['other-message'] )->text(); - } elseif ( isset( $params['other'] ) ) { - $msg = $params['other']; - } else { - $msg = wfMessage( 'htmlform-selectorother-other' )->text(); - } - $this->mOptions[$msg] = 'other'; - } - } - - function loadDataFromRequest( $request ) { - if ( $request->getCheck( $this->mName ) ) { - $val = $request->getText( $this->mName . '-select', 'other' ); - - if ( $val === 'other' ) { - $val = $request->getText( $this->mName ); - if ( isset( $this->autocomplete[$val] ) ) { - $val = $this->autocomplete[$val]; - } - } - - return $val; - } else { - return $this->getDefault(); - } - } - - function validate( $value, $alldata ) { - $p = parent::validate( $value, $alldata ); - - if ( $p !== true ) { - return $p; - } - - $validOptions = HTMLFormField::flattenOptions( $this->getOptions() ); - - if ( in_array( strval( $value ), $validOptions, true ) ) { - return true; - } elseif ( in_array( strval( $value ), $this->autocomplete, true ) ) { - return true; - } elseif ( $this->mParams['require-match'] ) { - return $this->msg( 'htmlform-select-badoption' )->parse(); - } - - return true; - } - - // FIXME Ewww, this shouldn't be adding any attributes not requested in $list :( - public function getAttributes( array $list ) { - $attribs = [ - 'type' => 'text', - 'data-autocomplete' => FormatJson::encode( array_keys( $this->autocomplete ) ), - ] + parent::getAttributes( $list ); - - if ( $this->getOptions() ) { - $attribs['data-hide-if'] = FormatJson::encode( - [ '!==', $this->mName . '-select', 'other' ] - ); - } - - return $attribs; - } - - function getInputHTML( $value ) { - $oldClass = $this->mClass; - $this->mClass = (array)$this->mClass; - - $valInSelect = false; - $ret = ''; - - if ( $this->getOptions() ) { - if ( $value !== false ) { - $value = strval( $value ); - $valInSelect = in_array( - $value, HTMLFormField::flattenOptions( $this->getOptions() ), true - ); - } - - $selected = $valInSelect ? $value : 'other'; - $select = new XmlSelect( $this->mName . '-select', $this->mID . '-select', $selected ); - $select->addOptions( $this->getOptions() ); - $select->setAttribute( 'class', 'mw-htmlform-select-or-other' ); - - if ( !empty( $this->mParams['disabled'] ) ) { - $select->setAttribute( 'disabled', 'disabled' ); - } - - if ( isset( $this->mParams['tabindex'] ) ) { - $select->setAttribute( 'tabindex', $this->mParams['tabindex'] ); - } - - $ret = $select->getHTML() . "
    \n"; - - $this->mClass[] = 'mw-htmlform-hide-if'; - } - - if ( $valInSelect ) { - $value = ''; - } else { - $key = array_search( strval( $value ), $this->autocomplete, true ); - if ( $key !== false ) { - $value = $key; - } - } - - $this->mClass[] = 'mw-htmlform-autocomplete'; - $ret .= parent::getInputHTML( $valInSelect ? '' : $value ); - $this->mClass = $oldClass; - - return $ret; - } - - /** - * Get the OOUI version of this input. - * @param string $value - * @return false - */ - function getInputOOUI( $value ) { - // To be implemented, for now override the function from HTMLTextField - return false; - } -} diff --git a/includes/htmlform/HTMLButtonField.php b/includes/htmlform/HTMLButtonField.php deleted file mode 100644 index 64fe7eda9b..0000000000 --- a/includes/htmlform/HTMLButtonField.php +++ /dev/null @@ -1,132 +0,0 @@ -mFlags = $info['flags']; - } - - # Generate the label from a message, if possible - if ( isset( $info['buttonlabel-message'] ) ) { - $this->buttonLabel = $this->getMessage( $info['buttonlabel-message'] )->parse(); - } elseif ( isset( $info['buttonlabel'] ) ) { - if ( $info['buttonlabel'] === ' ' ) { - // Apparently some things set   directly and in an odd format - $this->buttonLabel = ' '; - } else { - $this->buttonLabel = htmlspecialchars( $info['buttonlabel'] ); - } - } elseif ( isset( $info['buttonlabel-raw'] ) ) { - $this->buttonLabel = $info['buttonlabel-raw']; - } - - $this->setShowEmptyLabel( false ); - - parent::__construct( $info ); - } - - public function getInputHTML( $value ) { - $flags = ''; - $prefix = 'mw-htmlform-'; - if ( $this->mParent instanceof VFormHTMLForm || - $this->mParent->getConfig()->get( 'UseMediaWikiUIEverywhere' ) - ) { - $prefix = 'mw-ui-'; - // add mw-ui-button separately, so the descriptor doesn't need to set it - $flags .= ' ' . $prefix . 'button'; - } - foreach ( $this->mFlags as $flag ) { - $flags .= ' ' . $prefix . $flag; - } - $attr = [ - 'class' => 'mw-htmlform-submit ' . $this->mClass . $flags, - 'id' => $this->mID, - 'type' => $this->buttonType, - 'name' => $this->mName, - 'value' => $this->getDefault(), - ] + $this->getAttributes( [ 'disabled', 'tabindex' ] ); - - if ( $this->isBadIE() ) { - return Html::element( 'input', $attr ); - } else { - return Html::rawElement( 'button', $attr, - $this->buttonLabel ?: htmlspecialchars( $this->getDefault() ) ); - } - } - - /** - * Get the OOUI widget for this field. - * @param string $value - * @return OOUI\ButtonInputWidget - */ - public function getInputOOUI( $value ) { - return new OOUI\ButtonInputWidget( [ - 'name' => $this->mName, - 'value' => $this->getDefault(), - 'label' => !$this->isBadIE() && $this->buttonLabel - ? new OOUI\HtmlSnippet( $this->buttonLabel ) - : $this->getDefault(), - 'type' => $this->buttonType, - 'classes' => [ 'mw-htmlform-submit', $this->mClass ], - 'id' => $this->mID, - 'flags' => $this->mFlags, - 'useInputTag' => $this->isBadIE(), - ] + OOUI\Element::configFromHtmlAttributes( - $this->getAttributes( [ 'disabled', 'tabindex' ] ) - ) ); - } - - protected function needsLabel() { - return false; - } - - /** - * Button cannot be invalid - * - * @param string $value - * @param array $alldata - * - * @return bool - */ - public function validate( $value, $alldata ) { - return true; - } - - /** - * IE<8 has bugs with ', + $cases[] = [ '', 'button', [ 'formaction' => 'GET' ] ]; - $cases[] = [ '', + $cases[] = [ '', 'button', [ 'formenctype' => 'application/x-www-form-urlencoded' ] ]; @@ -553,10 +512,6 @@ class HtmlTest extends MediaWikiTestCase { 'canvas', [ 'width' => 300 ] ]; - $cases[] = [ '', - 'command', [ 'type' => 'command' ] - ]; - $cases[] = [ '
    ', 'form', [ 'action' => 'GET' ] ]; @@ -567,18 +522,18 @@ class HtmlTest extends MediaWikiTestCase { 'form', [ 'enctype' => 'application/x-www-form-urlencoded' ] ]; - $cases[] = [ '', + $cases[] = [ '', 'input', [ 'formaction' => 'GET' ] ]; - $cases[] = [ '', + $cases[] = [ '', 'input', [ 'type' => 'text' ] ]; - $cases[] = [ '', + $cases[] = [ '', 'keygen', [ 'keytype' => 'rsa' ] ]; - $cases[] = [ '', + $cases[] = [ '', 'link', [ 'media' => 'all' ] ]; @@ -604,44 +559,44 @@ class HtmlTest extends MediaWikiTestCase { # ## SPECIFIC CASES # - $cases[] = [ '', + $cases[] = [ '', 'link', [ 'type' => 'text/css' ] ]; # specific handling - $cases[] = [ '', + $cases[] = [ '', 'input', [ 'type' => 'checkbox', 'value' => 'on' ], 'Default value "on" is stripped of checkboxes', ]; - $cases[] = [ '', + $cases[] = [ '', 'input', [ 'type' => 'radio', 'value' => 'on' ], 'Default value "on" is stripped of radio buttons', ]; - $cases[] = [ '', + $cases[] = [ '', 'input', [ 'type' => 'submit', 'value' => 'Submit' ], 'Default value "Submit" is kept on submit buttons (for possible l10n issues)', ]; - $cases[] = [ '', + $cases[] = [ '', 'input', [ 'type' => 'color', 'value' => '' ], ]; - $cases[] = [ '', + $cases[] = [ '', 'input', [ 'type' => 'range', 'value' => '' ], ]; # ', + $cases[] = [ '', 'button', [ 'type' => 'submit' ], 'According to standard the default type is "submit". ' . 'Depending on compatibility mode IE might use "button", instead.', ]; # ', + $cases[] = [ '', 'select', [ 'size' => '4', 'multiple' => true ], ]; # .. with numeric value - $cases[] = [ '', + $cases[] = [ '', 'select', [ 'size' => 4, 'multiple' => true ], ]; $cases[] = [ '', @@ -693,7 +648,7 @@ class HtmlTest extends MediaWikiTestCase { 'Blacklist form validation attributes.' ); $this->assertEquals( - ' step=any', + ' step="any"', Html::expandAttributes( [ 'min' => 1, @@ -709,12 +664,12 @@ class HtmlTest extends MediaWikiTestCase { public function testWrapperInput() { $this->assertEquals( - '', + '', Html::input( 'testname', 'testval', 'radio' ), 'Input wrapper with type and value.' ); $this->assertEquals( - '', + '', Html::input( 'testname' ), 'Input wrapper with all default values.' ); @@ -722,17 +677,17 @@ class HtmlTest extends MediaWikiTestCase { public function testWrapperCheck() { $this->assertEquals( - '', + '', Html::check( 'testname' ), 'Checkbox wrapper unchecked.' ); $this->assertEquals( - '', + '', Html::check( 'testname', true ), 'Checkbox wrapper checked.' ); $this->assertEquals( - '', + '', Html::check( 'testname', false, [ 'value' => 'testval' ] ), 'Checkbox wrapper with a value override.' ); @@ -740,17 +695,17 @@ class HtmlTest extends MediaWikiTestCase { public function testWrapperRadio() { $this->assertEquals( - '', + '', Html::radio( 'testname' ), 'Radio wrapper unchecked.' ); $this->assertEquals( - '', + '', Html::radio( 'testname', true ), 'Radio wrapper checked.' ); $this->assertEquals( - '', + '', Html::radio( 'testname', false, [ 'value' => 'testval' ] ), 'Radio wrapper with a value override.' ); @@ -758,7 +713,7 @@ class HtmlTest extends MediaWikiTestCase { public function testWrapperLabel() { $this->assertEquals( - '', + '', Html::label( 'testlabel', 'testid' ), 'Label wrapper' ); @@ -777,6 +732,16 @@ class HtmlTest extends MediaWikiTestCase { '1x.png 1x, 1_5x.png 1.5x, 2x.png 2x', 'pixel depth keys may omit a trailing "x"' ], + [ + [ '1' => 'small.png', '1.5' => 'large.png', '2' => 'large.png' ], + 'small.png 1x, large.png 1.5x', + 'omit larger duplicates' + ], + [ + [ '1' => 'small.png', '2' => 'large.png', '1.5' => 'large.png' ], + 'small.png 1x, large.png 1.5x', + 'omit larger duplicates in irregular order' + ], ]; } diff --git a/tests/phpunit/includes/HttpTest.php b/tests/phpunit/includes/HttpTest.php deleted file mode 100644 index 4c2e02be25..0000000000 --- a/tests/phpunit/includes/HttpTest.php +++ /dev/null @@ -1,534 +0,0 @@ -assertEquals( $expected, $ok, $msg ); - } - - public static function cookieDomains() { - return [ - [ false, "org" ], - [ false, ".org" ], - [ true, "wikipedia.org" ], - [ true, ".wikipedia.org" ], - [ false, "co.uk" ], - [ false, ".co.uk" ], - [ false, "gov.uk" ], - [ false, ".gov.uk" ], - [ true, "supermarket.uk" ], - [ false, "uk" ], - [ false, ".uk" ], - [ false, "127.0.0." ], - [ false, "127." ], - [ false, "127.0.0.1." ], - [ true, "127.0.0.1" ], - [ false, "333.0.0.1" ], - [ true, "example.com" ], - [ false, "example.com." ], - [ true, ".example.com" ], - - [ true, ".example.com", "www.example.com" ], - [ false, "example.com", "www.example.com" ], - [ true, "127.0.0.1", "127.0.0.1" ], - [ false, "127.0.0.1", "localhost" ], - ]; - } - - /** - * Test Http::isValidURI() - * @bug 27854 : Http::isValidURI is too lax - * @dataProvider provideURI - * @covers Http::isValidURI - */ - public function testIsValidUri( $expect, $URI, $message = '' ) { - $this->assertEquals( - $expect, - (bool)Http::isValidURI( $URI ), - $message - ); - } - - /** - * @covers Http::getProxy - */ - public function testGetProxy() { - $this->setMwGlobals( 'wgHTTPProxy', 'proxy.domain.tld' ); - $this->assertEquals( - 'proxy.domain.tld', - Http::getProxy() - ); - } - - /** - * Feeds URI to test a long regular expression in Http::isValidURI - */ - public static function provideURI() { - /** Format: 'boolean expectation', 'URI to test', 'Optional message' */ - return [ - [ false, '¿non sens before!! http://a', 'Allow anything before URI' ], - - # (http|https) - only two schemes allowed - [ true, 'http://www.example.org/' ], - [ true, 'https://www.example.org/' ], - [ true, 'http://www.example.org', 'URI without directory' ], - [ true, 'http://a', 'Short name' ], - [ true, 'http://étoile', 'Allow UTF-8 in hostname' ], # 'étoile' is french for 'star' - [ false, '\\host\directory', 'CIFS share' ], - [ false, 'gopher://host/dir', 'Reject gopher scheme' ], - [ false, 'telnet://host', 'Reject telnet scheme' ], - - # :\/\/ - double slashes - [ false, 'http//example.org', 'Reject missing colon in protocol' ], - [ false, 'http:/example.org', 'Reject missing slash in protocol' ], - [ false, 'http:example.org', 'Must have two slashes' ], - # Following fail since hostname can be made of anything - [ false, 'http:///example.org', 'Must have exactly two slashes, not three' ], - - # (\w+:{0,1}\w*@)? - optional user:pass - [ true, 'http://user@host', 'Username provided' ], - [ true, 'http://user:@host', 'Username provided, no password' ], - [ true, 'http://user:pass@host', 'Username and password provided' ], - - # (\S+) - host part is made of anything not whitespaces - // commented these out in order to remove @group Broken - // @todo are these valid tests? if so, fix Http::isValidURI so it can handle them - // array( false, 'http://!"èèè¿¿¿~~\'', 'hostname is made of any non whitespace' ), - // array( false, 'http://exam:ple.org/', 'hostname can not use colons!' ), - - # (:[0-9]+)? - port number - [ true, 'http://example.org:80/' ], - [ true, 'https://example.org:80/' ], - [ true, 'http://example.org:443/' ], - [ true, 'https://example.org:443/' ], - - # Part after the hostname is / or / with something else - [ true, 'http://example/#' ], - [ true, 'http://example/!' ], - [ true, 'http://example/:' ], - [ true, 'http://example/.' ], - [ true, 'http://example/?' ], - [ true, 'http://example/+' ], - [ true, 'http://example/=' ], - [ true, 'http://example/&' ], - [ true, 'http://example/%' ], - [ true, 'http://example/@' ], - [ true, 'http://example/-' ], - [ true, 'http://example//' ], - [ true, 'http://example/&' ], - - # Fragment - [ true, 'http://exam#ple.org', ], # This one is valid, really! - [ true, 'http://example.org:80#anchor' ], - [ true, 'http://example.org/?id#anchor' ], - [ true, 'http://example.org/?#anchor' ], - - [ false, 'http://a ¿non !!sens after', 'Allow anything after URI' ], - ]; - } - - /** - * Warning: - * - * These tests are for code that makes use of an artifact of how CURL - * handles header reporting on redirect pages, and will need to be - * rewritten when bug 29232 is taken care of (high-level handling of - * HTTP redirects). - */ - public function testRelativeRedirections() { - $h = MWHttpRequestTester::factory( 'http://oldsite/file.ext', [], __METHOD__ ); - - # Forge a Location header - $h->setRespHeaders( 'location', [ - 'http://newsite/file.ext', - '/newfile.ext', - ] - ); - # Verify we correctly fix the Location - $this->assertEquals( - 'http://newsite/newfile.ext', - $h->getFinalUrl(), - "Relative file path Location: interpreted as full URL" - ); - - $h->setRespHeaders( 'location', [ - 'https://oldsite/file.ext' - ] - ); - $this->assertEquals( - 'https://oldsite/file.ext', - $h->getFinalUrl(), - "Location to the HTTPS version of the site" - ); - - $h->setRespHeaders( 'location', [ - '/anotherfile.ext', - 'http://anotherfile/hoster.ext', - 'https://anotherfile/hoster.ext' - ] - ); - $this->assertEquals( - 'https://anotherfile/hoster.ext', - $h->getFinalUrl( "Relative file path Location: should keep the latest host and scheme!" ) - ); - } - - /** - * Constant values are from PHP 5.3.28 using cURL 7.24.0 - * @see http://php.net/manual/en/curl.constants.php - * - * All constant values are present so that developers don’t need to remember - * to add them if added at a later date. The commented out constants were - * not found anywhere in the MediaWiki core code. - * - * Commented out constants that were not available in: - * HipHop VM 3.3.0 (rel) - * Compiler: heads/master-0-g08810d920dfff59e0774cf2d651f92f13a637175 - * Repo schema: 3214fc2c684a4520485f715ee45f33f2182324b1 - * Extension API: 20140829 - * - * Commented out constants that were removed in PHP 5.6.0 - * - * @covers CurlHttpRequest::execute - */ - public function provideCurlConstants() { - return [ - [ 'CURLAUTH_ANY' ], - [ 'CURLAUTH_ANYSAFE' ], - [ 'CURLAUTH_BASIC' ], - [ 'CURLAUTH_DIGEST' ], - [ 'CURLAUTH_GSSNEGOTIATE' ], - [ 'CURLAUTH_NTLM' ], - // array( 'CURLCLOSEPOLICY_CALLBACK' ), // removed in PHP 5.6.0 - // array( 'CURLCLOSEPOLICY_LEAST_RECENTLY_USED' ), // removed in PHP 5.6.0 - // array( 'CURLCLOSEPOLICY_LEAST_TRAFFIC' ), // removed in PHP 5.6.0 - // array( 'CURLCLOSEPOLICY_OLDEST' ), // removed in PHP 5.6.0 - // array( 'CURLCLOSEPOLICY_SLOWEST' ), // removed in PHP 5.6.0 - [ 'CURLE_ABORTED_BY_CALLBACK' ], - [ 'CURLE_BAD_CALLING_ORDER' ], - [ 'CURLE_BAD_CONTENT_ENCODING' ], - [ 'CURLE_BAD_FUNCTION_ARGUMENT' ], - [ 'CURLE_BAD_PASSWORD_ENTERED' ], - [ 'CURLE_COULDNT_CONNECT' ], - [ 'CURLE_COULDNT_RESOLVE_HOST' ], - [ 'CURLE_COULDNT_RESOLVE_PROXY' ], - [ 'CURLE_FAILED_INIT' ], - [ 'CURLE_FILESIZE_EXCEEDED' ], - [ 'CURLE_FILE_COULDNT_READ_FILE' ], - [ 'CURLE_FTP_ACCESS_DENIED' ], - [ 'CURLE_FTP_BAD_DOWNLOAD_RESUME' ], - [ 'CURLE_FTP_CANT_GET_HOST' ], - [ 'CURLE_FTP_CANT_RECONNECT' ], - [ 'CURLE_FTP_COULDNT_GET_SIZE' ], - [ 'CURLE_FTP_COULDNT_RETR_FILE' ], - [ 'CURLE_FTP_COULDNT_SET_ASCII' ], - [ 'CURLE_FTP_COULDNT_SET_BINARY' ], - [ 'CURLE_FTP_COULDNT_STOR_FILE' ], - [ 'CURLE_FTP_COULDNT_USE_REST' ], - [ 'CURLE_FTP_PORT_FAILED' ], - [ 'CURLE_FTP_QUOTE_ERROR' ], - [ 'CURLE_FTP_SSL_FAILED' ], - [ 'CURLE_FTP_USER_PASSWORD_INCORRECT' ], - [ 'CURLE_FTP_WEIRD_227_FORMAT' ], - [ 'CURLE_FTP_WEIRD_PASS_REPLY' ], - [ 'CURLE_FTP_WEIRD_PASV_REPLY' ], - [ 'CURLE_FTP_WEIRD_SERVER_REPLY' ], - [ 'CURLE_FTP_WEIRD_USER_REPLY' ], - [ 'CURLE_FTP_WRITE_ERROR' ], - [ 'CURLE_FUNCTION_NOT_FOUND' ], - [ 'CURLE_GOT_NOTHING' ], - [ 'CURLE_HTTP_NOT_FOUND' ], - [ 'CURLE_HTTP_PORT_FAILED' ], - [ 'CURLE_HTTP_POST_ERROR' ], - [ 'CURLE_HTTP_RANGE_ERROR' ], - [ 'CURLE_LDAP_CANNOT_BIND' ], - [ 'CURLE_LDAP_INVALID_URL' ], - [ 'CURLE_LDAP_SEARCH_FAILED' ], - [ 'CURLE_LIBRARY_NOT_FOUND' ], - [ 'CURLE_MALFORMAT_USER' ], - [ 'CURLE_OBSOLETE' ], - [ 'CURLE_OK' ], - [ 'CURLE_OPERATION_TIMEOUTED' ], - [ 'CURLE_OUT_OF_MEMORY' ], - [ 'CURLE_PARTIAL_FILE' ], - [ 'CURLE_READ_ERROR' ], - [ 'CURLE_RECV_ERROR' ], - [ 'CURLE_SEND_ERROR' ], - [ 'CURLE_SHARE_IN_USE' ], - // array( 'CURLE_SSH' ), // not present in HHVM 3.3.0-dev - [ 'CURLE_SSL_CACERT' ], - [ 'CURLE_SSL_CERTPROBLEM' ], - [ 'CURLE_SSL_CIPHER' ], - [ 'CURLE_SSL_CONNECT_ERROR' ], - [ 'CURLE_SSL_ENGINE_NOTFOUND' ], - [ 'CURLE_SSL_ENGINE_SETFAILED' ], - [ 'CURLE_SSL_PEER_CERTIFICATE' ], - [ 'CURLE_TELNET_OPTION_SYNTAX' ], - [ 'CURLE_TOO_MANY_REDIRECTS' ], - [ 'CURLE_UNKNOWN_TELNET_OPTION' ], - [ 'CURLE_UNSUPPORTED_PROTOCOL' ], - [ 'CURLE_URL_MALFORMAT' ], - [ 'CURLE_URL_MALFORMAT_USER' ], - [ 'CURLE_WRITE_ERROR' ], - [ 'CURLFTPAUTH_DEFAULT' ], - [ 'CURLFTPAUTH_SSL' ], - [ 'CURLFTPAUTH_TLS' ], - // array( 'CURLFTPMETHOD_MULTICWD' ), // not present in HHVM 3.3.0-dev - // array( 'CURLFTPMETHOD_NOCWD' ), // not present in HHVM 3.3.0-dev - // array( 'CURLFTPMETHOD_SINGLECWD' ), // not present in HHVM 3.3.0-dev - [ 'CURLFTPSSL_ALL' ], - [ 'CURLFTPSSL_CONTROL' ], - [ 'CURLFTPSSL_NONE' ], - [ 'CURLFTPSSL_TRY' ], - // array( 'CURLINFO_CERTINFO' ), // not present in HHVM 3.3.0-dev - [ 'CURLINFO_CONNECT_TIME' ], - [ 'CURLINFO_CONTENT_LENGTH_DOWNLOAD' ], - [ 'CURLINFO_CONTENT_LENGTH_UPLOAD' ], - [ 'CURLINFO_CONTENT_TYPE' ], - [ 'CURLINFO_EFFECTIVE_URL' ], - [ 'CURLINFO_FILETIME' ], - [ 'CURLINFO_HEADER_OUT' ], - [ 'CURLINFO_HEADER_SIZE' ], - [ 'CURLINFO_HTTP_CODE' ], - [ 'CURLINFO_NAMELOOKUP_TIME' ], - [ 'CURLINFO_PRETRANSFER_TIME' ], - [ 'CURLINFO_PRIVATE' ], - [ 'CURLINFO_REDIRECT_COUNT' ], - [ 'CURLINFO_REDIRECT_TIME' ], - // array( 'CURLINFO_REDIRECT_URL' ), // not present in HHVM 3.3.0-dev - [ 'CURLINFO_REQUEST_SIZE' ], - [ 'CURLINFO_SIZE_DOWNLOAD' ], - [ 'CURLINFO_SIZE_UPLOAD' ], - [ 'CURLINFO_SPEED_DOWNLOAD' ], - [ 'CURLINFO_SPEED_UPLOAD' ], - [ 'CURLINFO_SSL_VERIFYRESULT' ], - [ 'CURLINFO_STARTTRANSFER_TIME' ], - [ 'CURLINFO_TOTAL_TIME' ], - [ 'CURLMSG_DONE' ], - [ 'CURLM_BAD_EASY_HANDLE' ], - [ 'CURLM_BAD_HANDLE' ], - [ 'CURLM_CALL_MULTI_PERFORM' ], - [ 'CURLM_INTERNAL_ERROR' ], - [ 'CURLM_OK' ], - [ 'CURLM_OUT_OF_MEMORY' ], - [ 'CURLOPT_AUTOREFERER' ], - [ 'CURLOPT_BINARYTRANSFER' ], - [ 'CURLOPT_BUFFERSIZE' ], - [ 'CURLOPT_CAINFO' ], - [ 'CURLOPT_CAPATH' ], - // array( 'CURLOPT_CERTINFO' ), // not present in HHVM 3.3.0-dev - // array( 'CURLOPT_CLOSEPOLICY' ), // removed in PHP 5.6.0 - [ 'CURLOPT_CONNECTTIMEOUT' ], - [ 'CURLOPT_CONNECTTIMEOUT_MS' ], - [ 'CURLOPT_COOKIE' ], - [ 'CURLOPT_COOKIEFILE' ], - [ 'CURLOPT_COOKIEJAR' ], - [ 'CURLOPT_COOKIESESSION' ], - [ 'CURLOPT_CRLF' ], - [ 'CURLOPT_CUSTOMREQUEST' ], - [ 'CURLOPT_DNS_CACHE_TIMEOUT' ], - [ 'CURLOPT_DNS_USE_GLOBAL_CACHE' ], - [ 'CURLOPT_EGDSOCKET' ], - [ 'CURLOPT_ENCODING' ], - [ 'CURLOPT_FAILONERROR' ], - [ 'CURLOPT_FILE' ], - [ 'CURLOPT_FILETIME' ], - [ 'CURLOPT_FOLLOWLOCATION' ], - [ 'CURLOPT_FORBID_REUSE' ], - [ 'CURLOPT_FRESH_CONNECT' ], - [ 'CURLOPT_FTPAPPEND' ], - [ 'CURLOPT_FTPLISTONLY' ], - [ 'CURLOPT_FTPPORT' ], - [ 'CURLOPT_FTPSSLAUTH' ], - [ 'CURLOPT_FTP_CREATE_MISSING_DIRS' ], - // array( 'CURLOPT_FTP_FILEMETHOD' ), // not present in HHVM 3.3.0-dev - // array( 'CURLOPT_FTP_SKIP_PASV_IP' ), // not present in HHVM 3.3.0-dev - [ 'CURLOPT_FTP_SSL' ], - [ 'CURLOPT_FTP_USE_EPRT' ], - [ 'CURLOPT_FTP_USE_EPSV' ], - [ 'CURLOPT_HEADER' ], - [ 'CURLOPT_HEADERFUNCTION' ], - [ 'CURLOPT_HTTP200ALIASES' ], - [ 'CURLOPT_HTTPAUTH' ], - [ 'CURLOPT_HTTPGET' ], - [ 'CURLOPT_HTTPHEADER' ], - [ 'CURLOPT_HTTPPROXYTUNNEL' ], - [ 'CURLOPT_HTTP_VERSION' ], - [ 'CURLOPT_INFILE' ], - [ 'CURLOPT_INFILESIZE' ], - [ 'CURLOPT_INTERFACE' ], - [ 'CURLOPT_IPRESOLVE' ], - // array( 'CURLOPT_KEYPASSWD' ), // not present in HHVM 3.3.0-dev - [ 'CURLOPT_KRB4LEVEL' ], - [ 'CURLOPT_LOW_SPEED_LIMIT' ], - [ 'CURLOPT_LOW_SPEED_TIME' ], - [ 'CURLOPT_MAXCONNECTS' ], - [ 'CURLOPT_MAXREDIRS' ], - // array( 'CURLOPT_MAX_RECV_SPEED_LARGE' ), // not present in HHVM 3.3.0-dev - // array( 'CURLOPT_MAX_SEND_SPEED_LARGE' ), // not present in HHVM 3.3.0-dev - [ 'CURLOPT_NETRC' ], - [ 'CURLOPT_NOBODY' ], - [ 'CURLOPT_NOPROGRESS' ], - [ 'CURLOPT_NOSIGNAL' ], - [ 'CURLOPT_PORT' ], - [ 'CURLOPT_POST' ], - [ 'CURLOPT_POSTFIELDS' ], - [ 'CURLOPT_POSTQUOTE' ], - [ 'CURLOPT_POSTREDIR' ], - [ 'CURLOPT_PRIVATE' ], - [ 'CURLOPT_PROGRESSFUNCTION' ], - // array( 'CURLOPT_PROTOCOLS' ), // not present in HHVM 3.3.0-dev - [ 'CURLOPT_PROXY' ], - [ 'CURLOPT_PROXYAUTH' ], - [ 'CURLOPT_PROXYPORT' ], - [ 'CURLOPT_PROXYTYPE' ], - [ 'CURLOPT_PROXYUSERPWD' ], - [ 'CURLOPT_PUT' ], - [ 'CURLOPT_QUOTE' ], - [ 'CURLOPT_RANDOM_FILE' ], - [ 'CURLOPT_RANGE' ], - [ 'CURLOPT_READDATA' ], - [ 'CURLOPT_READFUNCTION' ], - // array( 'CURLOPT_REDIR_PROTOCOLS' ), // not present in HHVM 3.3.0-dev - [ 'CURLOPT_REFERER' ], - [ 'CURLOPT_RESUME_FROM' ], - [ 'CURLOPT_RETURNTRANSFER' ], - // array( 'CURLOPT_SSH_AUTH_TYPES' ), // not present in HHVM 3.3.0-dev - // array( 'CURLOPT_SSH_HOST_PUBLIC_KEY_MD5' ), // not present in HHVM 3.3.0-dev - // array( 'CURLOPT_SSH_PRIVATE_KEYFILE' ), // not present in HHVM 3.3.0-dev - // array( 'CURLOPT_SSH_PUBLIC_KEYFILE' ), // not present in HHVM 3.3.0-dev - [ 'CURLOPT_SSLCERT' ], - [ 'CURLOPT_SSLCERTPASSWD' ], - [ 'CURLOPT_SSLCERTTYPE' ], - [ 'CURLOPT_SSLENGINE' ], - [ 'CURLOPT_SSLENGINE_DEFAULT' ], - [ 'CURLOPT_SSLKEY' ], - [ 'CURLOPT_SSLKEYPASSWD' ], - [ 'CURLOPT_SSLKEYTYPE' ], - [ 'CURLOPT_SSLVERSION' ], - [ 'CURLOPT_SSL_CIPHER_LIST' ], - [ 'CURLOPT_SSL_VERIFYHOST' ], - [ 'CURLOPT_SSL_VERIFYPEER' ], - [ 'CURLOPT_STDERR' ], - [ 'CURLOPT_TCP_NODELAY' ], - [ 'CURLOPT_TIMECONDITION' ], - [ 'CURLOPT_TIMEOUT' ], - [ 'CURLOPT_TIMEOUT_MS' ], - [ 'CURLOPT_TIMEVALUE' ], - [ 'CURLOPT_TRANSFERTEXT' ], - [ 'CURLOPT_UNRESTRICTED_AUTH' ], - [ 'CURLOPT_UPLOAD' ], - [ 'CURLOPT_URL' ], - [ 'CURLOPT_USERAGENT' ], - [ 'CURLOPT_USERPWD' ], - [ 'CURLOPT_VERBOSE' ], - [ 'CURLOPT_WRITEFUNCTION' ], - [ 'CURLOPT_WRITEHEADER' ], - // array( 'CURLPROTO_ALL' ), // not present in HHVM 3.3.0-dev - // array( 'CURLPROTO_DICT' ), // not present in HHVM 3.3.0-dev - // array( 'CURLPROTO_FILE' ), // not present in HHVM 3.3.0-dev - // array( 'CURLPROTO_FTP' ), // not present in HHVM 3.3.0-dev - // array( 'CURLPROTO_FTPS' ), // not present in HHVM 3.3.0-dev - // array( 'CURLPROTO_HTTP' ), // not present in HHVM 3.3.0-dev - // array( 'CURLPROTO_HTTPS' ), // not present in HHVM 3.3.0-dev - // array( 'CURLPROTO_LDAP' ), // not present in HHVM 3.3.0-dev - // array( 'CURLPROTO_LDAPS' ), // not present in HHVM 3.3.0-dev - // array( 'CURLPROTO_SCP' ), // not present in HHVM 3.3.0-dev - // array( 'CURLPROTO_SFTP' ), // not present in HHVM 3.3.0-dev - // array( 'CURLPROTO_TELNET' ), // not present in HHVM 3.3.0-dev - // array( 'CURLPROTO_TFTP' ), // not present in HHVM 3.3.0-dev - [ 'CURLPROXY_HTTP' ], - // array( 'CURLPROXY_SOCKS4' ), // not present in HHVM 3.3.0-dev - [ 'CURLPROXY_SOCKS5' ], - // array( 'CURLSSH_AUTH_DEFAULT' ), // not present in HHVM 3.3.0-dev - // array( 'CURLSSH_AUTH_HOST' ), // not present in HHVM 3.3.0-dev - // array( 'CURLSSH_AUTH_KEYBOARD' ), // not present in HHVM 3.3.0-dev - // array( 'CURLSSH_AUTH_NONE' ), // not present in HHVM 3.3.0-dev - // array( 'CURLSSH_AUTH_PASSWORD' ), // not present in HHVM 3.3.0-dev - // array( 'CURLSSH_AUTH_PUBLICKEY' ), // not present in HHVM 3.3.0-dev - [ 'CURLVERSION_NOW' ], - [ 'CURL_HTTP_VERSION_1_0' ], - [ 'CURL_HTTP_VERSION_1_1' ], - [ 'CURL_HTTP_VERSION_NONE' ], - [ 'CURL_IPRESOLVE_V4' ], - [ 'CURL_IPRESOLVE_V6' ], - [ 'CURL_IPRESOLVE_WHATEVER' ], - [ 'CURL_NETRC_IGNORED' ], - [ 'CURL_NETRC_OPTIONAL' ], - [ 'CURL_NETRC_REQUIRED' ], - [ 'CURL_TIMECOND_IFMODSINCE' ], - [ 'CURL_TIMECOND_IFUNMODSINCE' ], - [ 'CURL_TIMECOND_LASTMOD' ], - [ 'CURL_VERSION_IPV6' ], - [ 'CURL_VERSION_KERBEROS4' ], - [ 'CURL_VERSION_LIBZ' ], - [ 'CURL_VERSION_SSL' ], - ]; - } - - /** - * Added this test based on an issue experienced with HHVM 3.3.0-dev - * where it did not define a cURL constant. - * - * @bug 70570 - * @dataProvider provideCurlConstants - */ - public function testCurlConstants( $value ) { - $this->assertTrue( defined( $value ), $value . ' not defined' ); - } -} - -/** - * Class to let us overwrite MWHttpRequest respHeaders variable - */ -class MWHttpRequestTester extends MWHttpRequest { - // function derived from the MWHttpRequest factory function but - // returns appropriate tester class here - public static function factory( $url, $options = null, $caller = __METHOD__ ) { - if ( !Http::$httpEngine ) { - Http::$httpEngine = function_exists( 'curl_init' ) ? 'curl' : 'php'; - } elseif ( Http::$httpEngine == 'curl' && !function_exists( 'curl_init' ) ) { - throw new MWException( __METHOD__ . ': curl (http://php.net/curl) is not installed, but' . - 'Http::$httpEngine is set to "curl"' ); - } - - switch ( Http::$httpEngine ) { - case 'curl': - return new CurlHttpRequestTester( $url, $options, $caller ); - case 'php': - if ( !wfIniGetBool( 'allow_url_fopen' ) ) { - throw new MWException( __METHOD__ . - ': allow_url_fopen needs to be enabled for pure PHP HTTP requests to work. ' - . 'If possible, curl should be used instead. See http://php.net/curl.' ); - } - - return new PhpHttpRequestTester( $url, $options, $caller ); - default: - } - } -} - -class CurlHttpRequestTester extends CurlHttpRequest { - function setRespHeaders( $name, $value ) { - $this->respHeaders[$name] = $value; - } -} - -class PhpHttpRequestTester extends PhpHttpRequest { - function setRespHeaders( $name, $value ) { - $this->respHeaders[$name] = $value; - } -} diff --git a/tests/phpunit/includes/LinkFilterTest.php b/tests/phpunit/includes/LinkFilterTest.php index 61b165a4dc..428b0129a0 100644 --- a/tests/phpunit/includes/LinkFilterTest.php +++ b/tests/phpunit/includes/LinkFilterTest.php @@ -6,7 +6,6 @@ class LinkFilterTest extends MediaWikiLangTestCase { protected function setUp() { - parent::setUp(); $this->setMwGlobals( 'wgUrlProtocols', [ @@ -26,7 +25,6 @@ class LinkFilterTest extends MediaWikiLangTestCase { 'mms://', '//', ] ); - } /** @@ -38,11 +36,9 @@ class LinkFilterTest extends MediaWikiLangTestCase { * @return string Regex */ function createRegexFromLIKE( $like ) { - $regex = '!^'; foreach ( $like as $item ) { - if ( $item instanceof LikeMatch ) { if ( $item->toString() == '%' ) { $regex .= '.*'; @@ -58,7 +54,6 @@ class LinkFilterTest extends MediaWikiLangTestCase { $regex .= '$!'; return $regex; - } /** @@ -67,7 +62,6 @@ class LinkFilterTest extends MediaWikiLangTestCase { * @return array */ public static function provideValidPatterns() { - return [ // Protocol, Search pattern, URL which matches the pattern [ 'http://', '*.test.com', 'http://www.test.com' ], @@ -164,7 +158,6 @@ class LinkFilterTest extends MediaWikiLangTestCase { // [ '', 'https://*.wikimedia.org/r/#/q/status:open,n,z', // 'https://gerrit.wikimedia.org/XXX/r/#/q/status:open,n,z', false ], ]; - } /** @@ -181,7 +174,6 @@ class LinkFilterTest extends MediaWikiLangTestCase { * @param bool $shouldBeFound Should the URL be found? (defaults true) */ function testMakeLikeArrayWithValidPatterns( $protocol, $pattern, $url, $shouldBeFound = true ) { - $indexes = wfMakeUrlIndexes( $url ); $likeArray = LinkFilter::makeLikeArray( $pattern, $protocol ); @@ -211,7 +203,6 @@ class LinkFilterTest extends MediaWikiLangTestCase { "Search pattern '$protocol$pattern' should not find url '$url' \n$debugmsg" ); } - } /** @@ -220,7 +211,6 @@ class LinkFilterTest extends MediaWikiLangTestCase { * @return array */ public static function provideInvalidPatterns() { - return [ [ '' ], [ '*' ], @@ -240,7 +230,6 @@ class LinkFilterTest extends MediaWikiLangTestCase { [ 'test.com/*/index' ], [ 'test.com/dir/index?arg=*' ], ]; - } /** @@ -253,12 +242,10 @@ class LinkFilterTest extends MediaWikiLangTestCase { * @param string $pattern Invalid search pattern */ function testMakeLikeArrayWithInvalidPatterns( $pattern ) { - $this->assertFalse( LinkFilter::makeLikeArray( $pattern ), "'$pattern' is not a valid pattern and should be rejected" ); - } } diff --git a/tests/phpunit/includes/LinkerTest.php b/tests/phpunit/includes/LinkerTest.php index e50b4f1490..3edf99f2e2 100644 --- a/tests/phpunit/includes/LinkerTest.php +++ b/tests/phpunit/includes/LinkerTest.php @@ -1,5 +1,7 @@ setMwGlobals( [ 'wgArticlePath' => '/wiki/$1', - 'wgWellFormedXml' => true, ] ); - $this->assertEquals( $expected, - Linker::userLink( $userId, $userName, $altUserName, $msg ) + $this->assertEquals( + $expected, + Linker::userLink( $userId, $userName, $altUserName ), + $msg ); } @@ -33,35 +36,35 @@ class LinkerTest extends MediaWikiLangTestCase { # ## ANONYMOUS USER ######################################## [ 'JohnDoe', + . 'class="mw-userlink mw-anonuserlink" ' + . 'title="Special:Contributions/JohnDoe">JohnDoe', 0, 'JohnDoe', false, ], [ '::1', + . 'class="mw-userlink mw-anonuserlink" ' + . 'title="Special:Contributions/::1">::1', 0, '::1', false, 'Anonymous with pretty IPv6' ], [ '::1', + . 'class="mw-userlink mw-anonuserlink" ' + . 'title="Special:Contributions/0:0:0:0:0:0:0:1">::1', 0, '0:0:0:0:0:0:0:1', false, 'Anonymous with almost pretty IPv6' ], [ '::1', + . 'class="mw-userlink mw-anonuserlink" ' + . 'title="Special:Contributions/0000:0000:0000:0000:0000:0000:0000:0001">::1', 0, '0000:0000:0000:0000:0000:0000:0000:0001', false, 'Anonymous with full IPv6' ], [ 'AlternativeUsername', + . 'class="mw-userlink mw-anonuserlink" ' + . 'title="Special:Contributions/::1">AlternativeUsername', 0, '::1', 'AlternativeUsername', 'Anonymous with pretty IPv6 and an alternative username' ], @@ -69,15 +72,15 @@ class LinkerTest extends MediaWikiLangTestCase { # IPV4 [ '127.0.0.1', + . 'class="mw-userlink mw-anonuserlink" ' + . 'title="Special:Contributions/127.0.0.1">127.0.0.1', 0, '127.0.0.1', false, 'Anonymous with IPv4' ], [ 'AlternativeUsername', + . 'class="mw-userlink mw-anonuserlink" ' + . 'title="Special:Contributions/127.0.0.1">AlternativeUsername', 0, '127.0.0.1', 'AlternativeUsername', 'Anonymous with IPv4 and an alternative username' ], @@ -112,7 +115,6 @@ class LinkerTest extends MediaWikiLangTestCase { $this->setMwGlobals( [ 'wgScript' => '/wiki/index.php', 'wgArticlePath' => '/wiki/$1', - 'wgWellFormedXml' => true, 'wgCapitalLinks' => true, 'wgConf' => $conf, ] ); @@ -277,7 +279,6 @@ class LinkerTest extends MediaWikiLangTestCase { $this->setMwGlobals( [ 'wgScript' => '/wiki/index.php', 'wgArticlePath' => '/wiki/$1', - 'wgWellFormedXml' => true, 'wgCapitalLinks' => true, 'wgConf' => $conf, ] ); @@ -309,4 +310,168 @@ class LinkerTest extends MediaWikiLangTestCase { ]; // @codingStandardsIgnoreEnd } + + public static function provideLinkBeginHook() { + // @codingStandardsIgnoreStart Generic.Files.LineLength + return [ + // Modify $html + [ + function( $dummy, $title, &$html, &$attribs, &$query, &$options, &$ret ) { + $html = 'foobar'; + }, + 'foobar' + ], + // Modify $attribs + [ + function( $dummy, $title, &$html, &$attribs, &$query, &$options, &$ret ) { + $attribs['bar'] = 'baz'; + }, + 'Special:BlankPage' + ], + // Modify $query + [ + function( $dummy, $title, &$html, &$attribs, &$query, &$options, &$ret ) { + $query['bar'] = 'baz'; + }, + 'Special:BlankPage' + ], + // Force HTTP $options + [ + function( $dummy, $title, &$html, &$attribs, &$query, &$options, &$ret ) { + $options = [ 'http' ]; + }, + 'Special:BlankPage' + ], + // Force 'forcearticlepath' in $options + [ + function( $dummy, $title, &$html, &$attribs, &$query, &$options, &$ret ) { + $options = [ 'forcearticlepath' ]; + $query['foo'] = 'bar'; + }, + 'Special:BlankPage' + ], + // Abort early + [ + function( $dummy, $title, &$html, &$attribs, &$query, &$options, &$ret ) { + $ret = 'foobar'; + return false; + }, + 'foobar' + ], + ]; + // @codingStandardsIgnoreEnd + } + + /** + * @covers MediaWiki\Linker\LinkRenderer::runLegacyBeginHook + * @dataProvider provideLinkBeginHook + */ + public function testLinkBeginHook( $callback, $expected ) { + $this->setMwGlobals( [ + 'wgArticlePath' => '/wiki/$1', + 'wgServer' => '//example.org', + 'wgCanonicalServer' => 'http://example.org', + 'wgScriptPath' => '/w', + 'wgScript' => '/w/index.php', + ] ); + + $this->setMwGlobals( 'wgHooks', [ 'LinkBegin' => [ $callback ] ] ); + $title = SpecialPage::getTitleFor( 'Blankpage' ); + $out = Linker::link( $title ); + $this->assertEquals( $expected, $out ); + } + + public static function provideLinkEndHook() { + return [ + // Override $html + [ + function( $dummy, $title, $options, &$html, &$attribs, &$ret ) { + $html = 'foobar'; + }, + 'foobar' + ], + // Modify $attribs + [ + function( $dummy, $title, $options, &$html, &$attribs, &$ret ) { + $attribs['bar'] = 'baz'; + }, + 'Special:BlankPage' + ], + // Fully override return value and abort hook + [ + function( $dummy, $title, $options, &$html, &$attribs, &$ret ) { + $ret = 'blahblahblah'; + return false; + }, + 'blahblahblah' + ], + + ]; + } + + /** + * @covers MediaWiki\Linker\LinkRenderer::buildAElement + * @dataProvider provideLinkEndHook + */ + public function testLinkEndHook( $callback, $expected ) { + $this->setMwGlobals( [ + 'wgArticlePath' => '/wiki/$1', + ] ); + + $this->setMwGlobals( 'wgHooks', [ 'LinkEnd' => [ $callback ] ] ); + + $title = SpecialPage::getTitleFor( 'Blankpage' ); + $out = Linker::link( $title ); + $this->assertEquals( $expected, $out ); + } + + /** + * @covers Linker::getLinkColour + */ + public function testGetLinkColour() { + $this->hideDeprecated( 'Linker::getLinkColour' ); + $linkCache = MediaWikiServices::getInstance()->getLinkCache(); + $foobarTitle = Title::makeTitle( NS_MAIN, 'FooBar' ); + $redirectTitle = Title::makeTitle( NS_MAIN, 'Redirect' ); + $userTitle = Title::makeTitle( NS_USER, 'Someuser' ); + $linkCache->addGoodLinkObj( + 1, // id + $foobarTitle, + 10, // len + 0 // redir + ); + $linkCache->addGoodLinkObj( + 2, // id + $redirectTitle, + 10, // len + 1 // redir + ); + + $linkCache->addGoodLinkObj( + 3, // id + $userTitle, + 10, // len + 0 // redir + ); + + $this->assertEquals( + '', + Linker::getLinkColour( $foobarTitle, 0 ) + ); + + $this->assertEquals( + 'stub', + Linker::getLinkColour( $foobarTitle, 20 ) + ); + + $this->assertEquals( + 'mw-redirect', + Linker::getLinkColour( $redirectTitle, 0 ) + ); + + $this->assertEquals( + '', + Linker::getLinkColour( $userTitle, 20 ) + ); + } } diff --git a/tests/phpunit/includes/MWNamespaceTest.php b/tests/phpunit/includes/MWNamespaceTest.php index ca01aef682..24db44581e 100644 --- a/tests/phpunit/includes/MWNamespaceTest.php +++ b/tests/phpunit/includes/MWNamespaceTest.php @@ -387,7 +387,7 @@ class MWNamespaceTest extends MediaWikiTestCase { $wgContentNamespaces = 5; $this->assertEquals( [ NS_MAIN ], MWNamespace::getContentNamespaces() ); - # test $wgContentNamespaces === array() + # test $wgContentNamespaces === [] $wgContentNamespaces = []; $this->assertEquals( [ NS_MAIN ], MWNamespace::getContentNamespaces() ); @@ -474,7 +474,7 @@ class MWNamespaceTest extends MediaWikiTestCase { * global $wgCapitalLink setting to have extended coverage. * * MWNamespace::isCapitalized() rely on two global settings: - * $wgCapitalLinkOverrides = array(); by default + * $wgCapitalLinkOverrides = []; by default * $wgCapitalLinks = true; by default * This function test $wgCapitalLinks * diff --git a/tests/phpunit/includes/MWTimestampTest.php b/tests/phpunit/includes/MWTimestampTest.php index bca39824b7..4bca4788ac 100644 --- a/tests/phpunit/includes/MWTimestampTest.php +++ b/tests/phpunit/includes/MWTimestampTest.php @@ -4,7 +4,6 @@ * Tests timestamp parsing and output. */ class MWTimestampTest extends MediaWikiLangTestCase { - protected function setUp() { parent::setUp(); @@ -12,128 +11,6 @@ class MWTimestampTest extends MediaWikiLangTestCase { $this->setMwGlobals( 'wgHooks', [] ); } - /** - * @covers MWTimestamp::__construct - */ - public function testConstructWithNoTimestamp() { - $timestamp = new MWTimestamp(); - $this->assertInternalType( 'string', $timestamp->getTimestamp() ); - $this->assertNotEmpty( $timestamp->getTimestamp() ); - $this->assertNotEquals( false, strtotime( $timestamp->getTimestamp( TS_MW ) ) ); - } - - /** - * @covers MWTimestamp::__toString - */ - public function testToString() { - $timestamp = new MWTimestamp( '1406833268' ); // Equivalent to 20140731190108 - $this->assertEquals( '1406833268', $timestamp->__toString() ); - } - - public static function provideValidTimestampDifferences() { - return [ - [ '1406833268', '1406833269', '00 00 00 01' ], - [ '1406833268', '1406833329', '00 00 01 01' ], - [ '1406833268', '1406836929', '00 01 01 01' ], - [ '1406833268', '1406923329', '01 01 01 01' ], - ]; - } - - /** - * @dataProvider provideValidTimestampDifferences - * @covers MWTimestamp::diff - */ - public function testDiff( $timestamp1, $timestamp2, $expected ) { - $timestamp1 = new MWTimestamp( $timestamp1 ); - $timestamp2 = new MWTimestamp( $timestamp2 ); - $diff = $timestamp1->diff( $timestamp2 ); - $this->assertEquals( $expected, $diff->format( '%D %H %I %S' ) ); - } - - /** - * Test parsing of valid timestamps and outputing to MW format. - * @dataProvider provideValidTimestamps - * @covers MWTimestamp::getTimestamp - */ - public function testValidParse( $format, $original, $expected ) { - $timestamp = new MWTimestamp( $original ); - $this->assertEquals( $expected, $timestamp->getTimestamp( TS_MW ) ); - } - - /** - * Test outputting valid timestamps to different formats. - * @dataProvider provideValidTimestamps - * @covers MWTimestamp::getTimestamp - */ - public function testValidOutput( $format, $expected, $original ) { - $timestamp = new MWTimestamp( $original ); - $this->assertEquals( $expected, (string)$timestamp->getTimestamp( $format ) ); - } - - /** - * Test an invalid timestamp. - * @expectedException TimestampException - * @covers MWTimestamp - */ - public function testInvalidParse() { - new MWTimestamp( "This is not a timestamp." ); - } - - /** - * Test an out of range timestamp - * @dataProvider provideOutOfRangeTimestamps - * @expectedException TimestampException - * @covers MWTimestamp - */ - public function testOutOfRangeTimestamps( $format, $input ) { - $timestamp = new MWTimestamp( $input ); - $timestamp->getTimestamp( $format ); - } - - /** - * Test requesting an invalid output format. - * @expectedException TimestampException - * @covers MWTimestamp::getTimestamp - */ - public function testInvalidOutput() { - $timestamp = new MWTimestamp( '1343761268' ); - $timestamp->getTimestamp( 98 ); - } - - /** - * Returns a list of valid timestamps in the format: - * array( type, timestamp_of_type, timestamp_in_MW ) - */ - public static function provideValidTimestamps() { - return [ - // Various formats - [ TS_UNIX, '1343761268', '20120731190108' ], - [ TS_MW, '20120731190108', '20120731190108' ], - [ TS_DB, '2012-07-31 19:01:08', '20120731190108' ], - [ TS_ISO_8601, '2012-07-31T19:01:08Z', '20120731190108' ], - [ TS_ISO_8601_BASIC, '20120731T190108Z', '20120731190108' ], - [ TS_EXIF, '2012:07:31 19:01:08', '20120731190108' ], - [ TS_RFC2822, 'Tue, 31 Jul 2012 19:01:08 GMT', '20120731190108' ], - [ TS_ORACLE, '31-07-2012 19:01:08.000000', '20120731190108' ], - [ TS_POSTGRES, '2012-07-31 19:01:08 GMT', '20120731190108' ], - // Some extremes and weird values - [ TS_ISO_8601, '9999-12-31T23:59:59Z', '99991231235959' ], - [ TS_UNIX, '-62135596801', '00001231235959' ] - ]; - } - - /** - * Returns a list of out of range timestamps in the format: - * array( type, timestamp_of_type ) - */ - public static function provideOutOfRangeTimestamps() { - return [ - // Various formats - [ TS_MW, '-62167219201' ], // -0001-12-31T23:59:59Z - [ TS_MW, '253402300800' ], // 10000-01-01T00:00:00Z - ]; - } - /** * @dataProvider provideHumanTimestampTests * @covers MWTimestamp::getHumanTimestamp diff --git a/tests/phpunit/includes/MediaWikiServicesTest.php b/tests/phpunit/includes/MediaWikiServicesTest.php index 6c38d503f7..dc0c64c4f6 100644 --- a/tests/phpunit/includes/MediaWikiServicesTest.php +++ b/tests/phpunit/includes/MediaWikiServicesTest.php @@ -1,6 +1,11 @@ newMediaWikiServices(); $oldServices = MediaWikiServices::forceGlobalInstance( $newServices ); + $service1 = $this->getMock( SalvageableService::class ); + $service1->expects( $this->never() ) + ->method( 'salvage' ); + + $newServices->defineService( + 'Test', + function() use ( $service1 ) { + return $service1; + } + ); + + // force instantiation + $newServices->getService( 'Test' ); + MediaWikiServices::resetGlobalInstance( $this->newTestConfig() ); $theServices = MediaWikiServices::getInstance(); + $this->assertSame( + $service1, + $theServices->getService( 'Test' ), + 'service definition should survive reset' + ); + + $this->assertNotSame( $theServices, $newServices ); + $this->assertNotSame( $theServices, $oldServices ); + + MediaWikiServices::forceGlobalInstance( $oldServices ); + } + + public function testResetGlobalInstance_quick() { + $newServices = $this->newMediaWikiServices(); + $oldServices = MediaWikiServices::forceGlobalInstance( $newServices ); + + $service1 = $this->getMock( SalvageableService::class ); + $service1->expects( $this->never() ) + ->method( 'salvage' ); + + $service2 = $this->getMock( SalvageableService::class ); + $service2->expects( $this->once() ) + ->method( 'salvage' ) + ->with( $service1 ); + + // sequence of values the instantiator will return + $instantiatorReturnValues = [ + $service1, + $service2, + ]; + + $newServices->defineService( + 'Test', + function() use ( &$instantiatorReturnValues ) { + return array_shift( $instantiatorReturnValues ); + } + ); + + // force instantiation + $newServices->getService( 'Test' ); + + MediaWikiServices::resetGlobalInstance( $this->newTestConfig(), 'quick' ); + $theServices = MediaWikiServices::getInstance(); + + $this->assertSame( $service2, $theServices->getService( 'Test' ) ); + $this->assertNotSame( $theServices, $newServices ); $this->assertNotSame( $theServices, $oldServices ); @@ -82,9 +147,6 @@ class MediaWikiServicesTest extends PHPUnit_Framework_TestCase { ->disableOriginalConstructor() ->getMock(); - $lbFactory->expects( $this->once() ) - ->method( 'destroy' ); - $newServices->redefineService( 'DBLoadBalancerFactory', function() use ( $lbFactory ) { @@ -99,45 +161,51 @@ class MediaWikiServicesTest extends PHPUnit_Framework_TestCase { try { MediaWikiServices::getInstance()->getService( 'DBLoadBalancerFactory' ); - $this->fail( 'DBLoadBalancerFactory shoudl have been disabled' ); + $this->fail( 'DBLoadBalancerFactory should have been disabled' ); } catch ( ServiceDisabledException $ex ) { // ok, as expected - } - catch ( Throwable $ex ) { + } catch ( Throwable $ex ) { $this->fail( 'ServiceDisabledException expected, caught ' . get_class( $ex ) ); } MediaWikiServices::forceGlobalInstance( $oldServices ); + $newServices->destroy(); } public function testResetChildProcessServices() { $newServices = $this->newMediaWikiServices(); $oldServices = MediaWikiServices::forceGlobalInstance( $newServices ); - $lbFactory = $this->getMockBuilder( 'LBFactorySimple' ) - ->disableOriginalConstructor() - ->getMock(); + $service1 = $this->getMock( DestructibleService::class ); + $service1->expects( $this->once() ) + ->method( 'destroy' ); - $lbFactory->expects( $this->once() ) + $service2 = $this->getMock( DestructibleService::class ); + $service2->expects( $this->never() ) ->method( 'destroy' ); - $newServices->redefineService( - 'DBLoadBalancerFactory', - function() use ( $lbFactory ) { - return $lbFactory; + // sequence of values the instantiator will return + $instantiatorReturnValues = [ + $service1, + $service2, + ]; + + $newServices->defineService( + 'Test', + function() use ( &$instantiatorReturnValues ) { + return array_shift( $instantiatorReturnValues ); } ); // force the service to become active, so we can check that it does get destroyed - $oldLBFactory = $newServices->getService( 'DBLoadBalancerFactory' ); + $oldTestService = $newServices->getService( 'Test' ); MediaWikiServices::resetChildProcessServices(); $finalServices = MediaWikiServices::getInstance(); - $newLBFactory = $finalServices->getService( 'DBLoadBalancerFactory' ); - - $this->assertNotSame( $oldLBFactory, $newLBFactory ); + $newTestService = $finalServices->getService( 'Test' ); + $this->assertNotSame( $oldTestService, $newTestService ); MediaWikiServices::forceGlobalInstance( $oldServices ); } @@ -200,6 +268,10 @@ class MediaWikiServicesTest extends PHPUnit_Framework_TestCase { // All getters should be named just like the service, with "get" added. foreach ( $getServiceCases as $name => $case ) { + if ( $name[0] === '_' ) { + // Internal service, no getter + continue; + } list( $service, $class ) = $case; $getterCases[$name] = [ 'get' . $service, @@ -231,6 +303,7 @@ class MediaWikiServicesTest extends PHPUnit_Framework_TestCase { 'SiteStore' => [ 'SiteStore', SiteStore::class ], 'SiteLookup' => [ 'SiteLookup', SiteLookup::class ], 'StatsdDataFactory' => [ 'StatsdDataFactory', StatsdDataFactory::class ], + 'InterwikiLookup' => [ 'InterwikiLookup', InterwikiLookup::class ], 'EventRelayerGroup' => [ 'EventRelayerGroup', EventRelayerGroup::class ], 'SearchEngineFactory' => [ 'SearchEngineFactory', SearchEngineFactory::class ], 'SearchEngineConfig' => [ 'SearchEngineConfig', SearchEngineConfig::class ], @@ -238,6 +311,24 @@ class MediaWikiServicesTest extends PHPUnit_Framework_TestCase { 'DBLoadBalancerFactory' => [ 'DBLoadBalancerFactory', 'LBFactory' ], 'DBLoadBalancer' => [ 'DBLoadBalancer', 'LoadBalancer' ], 'WatchedItemStore' => [ 'WatchedItemStore', WatchedItemStore::class ], + 'WatchedItemQueryService' => [ 'WatchedItemQueryService', WatchedItemQueryService::class ], + 'CryptRand' => [ 'CryptRand', CryptRand::class ], + 'CryptHKDF' => [ 'CryptHKDF', CryptHKDF::class ], + 'MediaHandlerFactory' => [ 'MediaHandlerFactory', MediaHandlerFactory::class ], + 'Parser' => [ 'Parser', Parser::class ], + 'GenderCache' => [ 'GenderCache', GenderCache::class ], + 'LinkCache' => [ 'LinkCache', LinkCache::class ], + 'LinkRenderer' => [ 'LinkRenderer', LinkRenderer::class ], + 'LinkRendererFactory' => [ 'LinkRendererFactory', LinkRendererFactory::class ], + '_MediaWikiTitleCodec' => [ '_MediaWikiTitleCodec', MediaWikiTitleCodec::class ], + 'MimeAnalyzer' => [ 'MimeAnalyzer', MimeAnalyzer::class ], + 'TitleFormatter' => [ 'TitleFormatter', TitleFormatter::class ], + 'TitleParser' => [ 'TitleParser', TitleParser::class ], + 'ProxyLookup' => [ 'ProxyLookup', ProxyLookup::class ], + 'MainObjectStash' => [ 'MainObjectStash', BagOStuff::class ], + 'MainWANObjectCache' => [ 'MainWANObjectCache', WANObjectCache::class ], + 'LocalServerObjectCache' => [ 'LocalServerObjectCache', BagOStuff::class ], + 'VirtualRESTServiceClient' => [ 'VirtualRESTServiceClient', VirtualRESTServiceClient::class ] ]; } diff --git a/tests/phpunit/includes/MediaWikiTest.php b/tests/phpunit/includes/MediaWikiTest.php index df92012e29..a8d1e33950 100644 --- a/tests/phpunit/includes/MediaWikiTest.php +++ b/tests/phpunit/includes/MediaWikiTest.php @@ -34,7 +34,7 @@ class MediaWikiTest extends MediaWikiTestCase { 'url' => 'http://example.org/w/index.php?title=Foo_Bar', 'query' => [ 'title' => 'Foo_Bar' ], 'title' => 'Foo_Bar', - 'redirect' => 'http://example.org/wiki/Foo_Bar', + 'redirect' => false, ], [ // View: Script path with implicit title from page id @@ -76,21 +76,21 @@ class MediaWikiTest extends MediaWikiTestCase { 'url' => 'http://example.org/w/?title=Foo_Bar', 'query' => [ 'title' => 'Foo_Bar' ], 'title' => 'Foo_Bar', - 'redirect' => 'http://example.org/wiki/Foo_Bar', + 'redirect' => false, ], [ // View: Root path with escaped title 'url' => 'http://example.org/?title=Foo_Bar', 'query' => [ 'title' => 'Foo_Bar' ], 'title' => 'Foo_Bar', - 'redirect' => 'http://example.org/wiki/Foo_Bar', + 'redirect' => false, ], [ // View: Canonical with redundant query 'url' => 'http://example.org/wiki/Foo_Bar?action=view', 'query' => [ 'action' => 'view' ], 'title' => 'Foo_Bar', - 'redirect' => 'http://example.org/wiki/Foo_Bar', + 'redirect' => false, ], [ // Edit: Canonical view url with action query @@ -104,7 +104,7 @@ class MediaWikiTest extends MediaWikiTestCase { 'url' => 'http://example.org/w/index.php?title=Foo_Bar&action=view', 'query' => [ 'title' => 'Foo_Bar', 'action' => 'view' ], 'title' => 'Foo_Bar', - 'redirect' => 'http://example.org/wiki/Foo_Bar', + 'redirect' => false, ], [ // Edit: Index with action query diff --git a/tests/phpunit/includes/MergeHistoryTest.php b/tests/phpunit/includes/MergeHistoryTest.php index 22f6fa6a58..f44ae32272 100644 --- a/tests/phpunit/includes/MergeHistoryTest.php +++ b/tests/phpunit/includes/MergeHistoryTest.php @@ -97,13 +97,12 @@ class MergeHistoryTest extends MediaWikiTestCase { ); // Sysop with mergehistory permission - $sysop = User::newFromName( 'UTSysop' ); + $sysop = static::getTestSysop()->getUser(); $status = $mh->checkPermissions( $sysop, '' ); $this->assertTrue( $status->isOK() ); // Normal user - $notSysop = User::newFromName( 'UTNotSysop' ); - $notSysop->addToDatabase(); + $notSysop = static::getTestUser()->getUser(); $status = $mh->checkPermissions( $notSysop, '' ); $this->assertTrue( $status->hasMessage( 'mergehistory-fail-permission' ) ); } @@ -118,7 +117,8 @@ class MergeHistoryTest extends MediaWikiTestCase { Title::newFromText( 'Merge2' ) ); - $mh->merge( User::newFromName( 'UTSysop' ) ); + $sysop = static::getTestSysop()->getUser(); + $mh->merge( $sysop ); $this->assertEquals( $mh->getMergedRevisionCount(), 1 ); } } diff --git a/tests/phpunit/includes/MessageTest.php b/tests/phpunit/includes/MessageTest.php index 224b0cbdfa..4fe806c438 100644 --- a/tests/phpunit/includes/MessageTest.php +++ b/tests/phpunit/includes/MessageTest.php @@ -18,8 +18,8 @@ class MessageTest extends MediaWikiLangTestCase { public function testConstructor( $expectedLang, $key, $params, $language ) { $message = new Message( $key, $params, $language ); - $this->assertEquals( $key, $message->getKey() ); - $this->assertEquals( $params, $message->getParams() ); + $this->assertSame( $key, $message->getKey() ); + $this->assertSame( $params, $message->getParams() ); $this->assertEquals( $expectedLang, $message->getLanguage() ); $messageSpecifier = $this->getMockForAbstractClass( 'MessageSpecifier' ); @@ -29,8 +29,8 @@ class MessageTest extends MediaWikiLangTestCase { ->method( 'getParams' )->will( $this->returnValue( $params ) ); $message = new Message( $messageSpecifier, [], $language ); - $this->assertEquals( $key, $message->getKey() ); - $this->assertEquals( $params, $message->getParams() ); + $this->assertSame( $key, $message->getKey() ); + $this->assertSame( $params, $message->getParams() ); $this->assertEquals( $expectedLang, $message->getLanguage() ); } @@ -97,7 +97,7 @@ class MessageTest extends MediaWikiLangTestCase { $returned = call_user_func_array( [ $msg, 'params' ], $args ); $this->assertSame( $msg, $returned ); - $this->assertEquals( $expected, $msg->getParams() ); + $this->assertSame( $expected, $msg->getParams() ); } public static function provideConstructorLanguage() { @@ -165,8 +165,8 @@ class MessageTest extends MediaWikiLangTestCase { $msg = new Message( $key ); $this->assertContains( $msg->getKey(), $expected ); - $this->assertEquals( $expected, $msg->getKeysToTry() ); - $this->assertEquals( count( $expected ) > 1, $msg->isMultiKey() ); + $this->assertSame( $expected, $msg->getKeysToTry() ); + $this->assertSame( count( $expected ) > 1, $msg->isMultiKey() ); } /** @@ -190,13 +190,13 @@ class MessageTest extends MediaWikiLangTestCase { * @covers Message::__construct */ public function testWfMessageParams() { - $this->assertEquals( 'Return to $1.', wfMessage( 'returnto' )->text() ); - $this->assertEquals( 'Return to $1.', wfMessage( 'returnto', [] )->text() ); - $this->assertEquals( + $this->assertSame( 'Return to $1.', wfMessage( 'returnto' )->text() ); + $this->assertSame( 'Return to $1.', wfMessage( 'returnto', [] )->text() ); + $this->assertSame( 'You have foo (bar).', wfMessage( 'youhavenewmessages', 'foo', 'bar' )->text() ); - $this->assertEquals( + $this->assertSame( 'You have foo (bar).', wfMessage( 'youhavenewmessages', [ 'foo', 'bar' ] )->text() ); @@ -222,23 +222,28 @@ class MessageTest extends MediaWikiLangTestCase { * @covers Message::toString */ public function testToStringKey() { - $this->assertEquals( 'Main Page', wfMessage( 'mainpage' )->text() ); - $this->assertEquals( '', wfMessage( 'i-dont-exist-evar' )->text() ); - $this->assertEquals( 'exist-evar>', wfMessage( 'iexist-evar' )->text() ); - $this->assertEquals( '', wfMessage( 'i-dont-exist-evar' )->plain() ); - $this->assertEquals( 'exist-evar>', wfMessage( 'iexist-evar' )->plain() ); - $this->assertEquals( '<i-dont-exist-evar>', wfMessage( 'i-dont-exist-evar' )->escaped() ); - $this->assertEquals( - '<i<dont>exist-evar>', + $this->assertSame( 'Main Page', wfMessage( 'mainpage' )->text() ); + $this->assertSame( '⧼i-dont-exist-evar⧽', wfMessage( 'i-dont-exist-evar' )->text() ); + $this->assertSame( '⧼i<dont>exist-evar⧽', wfMessage( 'iexist-evar' )->text() ); + $this->assertSame( '⧼i-dont-exist-evar⧽', wfMessage( 'i-dont-exist-evar' )->plain() ); + $this->assertSame( '⧼i<dont>exist-evar⧽', wfMessage( 'iexist-evar' )->plain() ); + $this->assertSame( '⧼i-dont-exist-evar⧽', wfMessage( 'i-dont-exist-evar' )->escaped() ); + $this->assertSame( + '⧼i<dont>exist-evar⧽', wfMessage( 'iexist-evar' )->escaped() ); } public static function provideToString() { return [ - [ 'mainpage', 'Main Page' ], - [ 'i-dont-exist-evar', '' ], - [ 'i-dont-exist-evar', '<i-dont-exist-evar>', 'escaped' ], + // key, transformation, transformed, transformed implicitly + [ 'mainpage', 'plain', 'Main Page', 'Main Page' ], + [ 'i-dont-exist-evar', 'plain', '⧼i-dont-exist-evar⧽', '⧼i-dont-exist-evar⧽' ], + [ 'i-dont-exist-evar', 'escaped', '⧼i-dont-exist-evar⧽', '⧼i-dont-exist-evar⧽' ], + [ 'script>alert(1)alert(1)$format(); - $this->assertEquals( $expect, $msg->toString() ); - $this->assertEquals( $expect, $msg->__toString() ); + $this->assertSame( $expect, $msg->$format() ); + $this->assertSame( $expect, $msg->toString(), 'toString is unaffected by previous call' ); + $this->assertSame( $expectImplicit, $msg->__toString() ); + $this->assertSame( $expect, $msg->toString(), 'toString is unaffected by __toString' ); + } + + public static function provideToString_raw() { + return [ + [ 'foo', 'parse', 'foo', 'foo' ], + [ 'foo', 'escaped', '<span>foo</span>', + 'foo' ], + [ 'foo', 'plain', 'foo', 'foo' ], + [ '', 'parse', '<script>alert(1)</script>', + '<script>alert(1)</script>' ], + [ '', 'escaped', '<script>alert(1)</script>', + '<script>alert(1)</script>' ], + [ '', 'plain', '', + '<script>alert(1)</script>' ], + ]; + } + + /** + * @covers Message::toString + * @covers Message::__toString + * @dataProvider provideToString_raw + */ + public function testToString_raw( $message, $format, $expect, $expectImplicit ) { + // make the message behave like RawMessage and use the key as-is + $msg = $this->getMockBuilder( Message::class )->setMethods( [ 'fetchMessage' ] ) + ->disableOriginalConstructor() + ->getMock(); + $msg->expects( $this->any() )->method( 'fetchMessage' )->willReturn( $message ); + /** @var Message $msg */ + $this->assertSame( $expect, $msg->$format() ); + $this->assertSame( $expect, $msg->toString(), 'toString is unaffected by previous call' ); + $this->assertSame( $expectImplicit, $msg->__toString() ); + $this->assertSame( $expect, $msg->toString(), 'toString is unaffected by __toString' ); } /** * @covers Message::inLanguage */ public function testInLanguage() { - $this->assertEquals( 'Main Page', wfMessage( 'mainpage' )->inLanguage( 'en' )->text() ); - $this->assertEquals( 'Заглавная страница', + $this->assertSame( 'Main Page', wfMessage( 'mainpage' )->inLanguage( 'en' )->text() ); + $this->assertSame( 'Заглавная страница', wfMessage( 'mainpage' )->inLanguage( 'ru' )->text() ); // NOTE: make sure internal caching of the message text is reset appropriately $msg = wfMessage( 'mainpage' ); - $this->assertEquals( 'Main Page', $msg->inLanguage( Language::factory( 'en' ) )->text() ); - $this->assertEquals( + $this->assertSame( 'Main Page', $msg->inLanguage( Language::factory( 'en' ) )->text() ); + $this->assertSame( 'Заглавная страница', $msg->inLanguage( Language::factory( 'ru' ) )->text() ); @@ -276,19 +315,19 @@ class MessageTest extends MediaWikiLangTestCase { * @covers Message::rawParams */ public function testRawParams() { - $this->assertEquals( + $this->assertSame( '(Заглавная страница)', wfMessage( 'parentheses', 'Заглавная страница' )->plain() ); - $this->assertEquals( + $this->assertSame( '(Заглавная страница $1)', wfMessage( 'parentheses', 'Заглавная страница $1' )->plain() ); - $this->assertEquals( + $this->assertSame( '(Заглавная страница)', wfMessage( 'parentheses' )->rawParams( 'Заглавная страница' )->plain() ); - $this->assertEquals( + $this->assertSame( '(Заглавная страница $1)', wfMessage( 'parentheses' )->rawParams( 'Заглавная страница $1' )->plain() ); @@ -300,8 +339,8 @@ class MessageTest extends MediaWikiLangTestCase { */ public function testRawMessage() { $msg = new RawMessage( 'example &' ); - $this->assertEquals( 'example &', $msg->plain() ); - $this->assertEquals( 'example &', $msg->escaped() ); + $this->assertSame( 'example &', $msg->plain() ); + $this->assertSame( 'example &', $msg->escaped() ); } /** @@ -313,7 +352,7 @@ class MessageTest extends MediaWikiLangTestCase { $msg = new RawMessage( '$1$2$3$4$5$6$7$8$9$10$11$12' ); // One less than above has placeholders $params = [ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k' ]; - $this->assertEquals( + $this->assertSame( 'abcdefghijka2', $msg->params( $params )->plain(), 'Params > 9 are replaced correctly' @@ -321,7 +360,7 @@ class MessageTest extends MediaWikiLangTestCase { $msg = new RawMessage( 'Params$*' ); $params = [ 'ab', 'bc', 'cd' ]; - $this->assertEquals( + $this->assertSame( 'Params: ab, bc, cd', $msg->params( $params )->text() ); @@ -335,7 +374,7 @@ class MessageTest extends MediaWikiLangTestCase { $lang = Language::factory( 'en' ); $msg = new RawMessage( '$1' ); - $this->assertEquals( + $this->assertSame( $lang->formatNum( 123456.789 ), $msg->inLanguage( $lang )->numParams( 123456.789 )->plain(), 'numParams is handled correctly' @@ -350,7 +389,7 @@ class MessageTest extends MediaWikiLangTestCase { $lang = Language::factory( 'en' ); $msg = new RawMessage( '$1' ); - $this->assertEquals( + $this->assertSame( $lang->formatDuration( 1234 ), $msg->inLanguage( $lang )->durationParams( 1234 )->plain(), 'durationParams is handled correctly' @@ -367,7 +406,7 @@ class MessageTest extends MediaWikiLangTestCase { $lang = Language::factory( 'en' ); $msg = new RawMessage( '$1' ); - $this->assertEquals( + $this->assertSame( $lang->formatExpiry( wfTimestampNow() ), $msg->inLanguage( $lang )->expiryParams( wfTimestampNow() )->plain(), 'expiryParams is handled correctly' @@ -382,7 +421,7 @@ class MessageTest extends MediaWikiLangTestCase { $lang = Language::factory( 'en' ); $msg = new RawMessage( '$1' ); - $this->assertEquals( + $this->assertSame( $lang->formatTimePeriod( 1234 ), $msg->inLanguage( $lang )->timeperiodParams( 1234 )->plain(), 'timeperiodParams is handled correctly' @@ -397,7 +436,7 @@ class MessageTest extends MediaWikiLangTestCase { $lang = Language::factory( 'en' ); $msg = new RawMessage( '$1' ); - $this->assertEquals( + $this->assertSame( $lang->formatSize( 123456 ), $msg->inLanguage( $lang )->sizeParams( 123456 )->plain(), 'sizeParams is handled correctly' @@ -412,7 +451,7 @@ class MessageTest extends MediaWikiLangTestCase { $lang = Language::factory( 'en' ); $msg = new RawMessage( '$1' ); - $this->assertEquals( + $this->assertSame( $lang->formatBitrate( 123456 ), $msg->inLanguage( $lang )->bitrateParams( 123456 )->plain(), 'bitrateParams is handled correctly' @@ -466,13 +505,143 @@ class MessageTest extends MediaWikiLangTestCase { 'one $2', '
    foo
    [[Bar]] {{Baz}} <', ]; - $this->assertEquals( + $this->assertSame( $expect, $msg->inLanguage( $lang )->plaintextParams( $params )->$format(), "Fail formatting for $format" ); } + public static function provideListParam() { + $lang = Language::factory( 'de' ); + $msg1 = new Message( 'mainpage', [], $lang ); + $msg2 = new RawMessage( "''link''", [], $lang ); + + return [ + 'Simple comma list' => [ + [ 'a', 'b', 'c' ], + 'comma', + 'text', + 'a, b, c' + ], + + 'Simple semicolon list' => [ + [ 'a', 'b', 'c' ], + 'semicolon', + 'text', + 'a; b; c' + ], + + 'Simple pipe list' => [ + [ 'a', 'b', 'c' ], + 'pipe', + 'text', + 'a | b | c' + ], + + 'Simple text list' => [ + [ 'a', 'b', 'c' ], + 'text', + 'text', + 'a, b and c' + ], + + 'Empty list' => [ + [], + 'comma', + 'text', + '' + ], + + 'List with all "before" params, ->text()' => [ + [ "''link''", Message::numParam( 12345678 ) ], + 'semicolon', + 'text', + '\'\'link\'\'; 12,345,678' + ], + + 'List with all "before" params, ->parse()' => [ + [ "''link''", Message::numParam( 12345678 ) ], + 'semicolon', + 'parse', + 'link; 12,345,678' + ], + + 'List with all "after" params, ->text()' => [ + [ $msg1, $msg2, Message::rawParam( '[[foo]]' ) ], + 'semicolon', + 'text', + 'Main Page; \'\'link\'\'; [[foo]]' + ], + + 'List with all "after" params, ->parse()' => [ + [ $msg1, $msg2, Message::rawParam( '[[foo]]' ) ], + 'semicolon', + 'parse', + 'Main Page; link; [[foo]]' + ], + + 'List with both "before" and "after" params, ->text()' => [ + [ $msg1, $msg2, Message::rawParam( '[[foo]]' ), "''link''", Message::numParam( 12345678 ) ], + 'semicolon', + 'text', + 'Main Page; \'\'link\'\'; [[foo]]; \'\'link\'\'; 12,345,678' + ], + + 'List with both "before" and "after" params, ->parse()' => [ + [ $msg1, $msg2, Message::rawParam( '[[foo]]' ), "''link''", Message::numParam( 12345678 ) ], + 'semicolon', + 'parse', + 'Main Page; link; [[foo]]; link; 12,345,678' + ], + ]; + } + + /** + * @covers Message::listParam + * @covers Message::extractParam + * @covers Message::formatListParam + * @dataProvider provideListParam + */ + public function testListParam( $list, $type, $format, $expect ) { + $lang = Language::factory( 'en' ); + + $msg = new RawMessage( '$1' ); + $msg->params( [ Message::listParam( $list, $type ) ] ); + $this->assertEquals( + $expect, + $msg->inLanguage( $lang )->$format() + ); + } + + /** + * @covers Message::extractParam + */ + public function testMessageAsParam() { + $this->setMwGlobals( [ + 'wgScript' => '/wiki/index.php', + 'wgArticlePath' => '/wiki/$1', + ] ); + + $msg = new Message( 'returnto', [ + new Message( 'apihelp-link', [ + 'foo', new Message( 'mainpage', [], Language::factory( 'en' ) ) + ], Language::factory( 'de' ) ) + ], Language::factory( 'es' ) ); + + $this->assertEquals( + 'Volver a [[Special:ApiHelp/foo|Página principal]].', + $msg->text(), + 'Process with ->text()' + ); + $this->assertEquals( + '

    Volver a Página ' + . "principal.\n

    ", + $msg->parseAsBlock(), + 'Process with ->parseAsBlock()' + ); + } + public static function provideParser() { return [ [ @@ -507,7 +676,7 @@ class MessageTest extends MediaWikiLangTestCase { */ public function testParser( $expect, $format ) { $msg = new RawMessage( "''&'' " ); - $this->assertEquals( + $this->assertSame( $expect, $msg->inLanguage( 'en' )->$format() ); @@ -521,9 +690,9 @@ class MessageTest extends MediaWikiLangTestCase { // NOTE: make sure internal caching of the message text is reset appropriately $msg = wfMessage( 'mainpage' ); - $this->assertEquals( 'Hauptseite', $msg->inLanguage( 'de' )->plain(), "inLanguage( 'de' )" ); - $this->assertEquals( 'Main Page', $msg->inContentLanguage()->plain(), "inContentLanguage()" ); - $this->assertEquals( 'Accueil', $msg->inLanguage( 'fr' )->plain(), "inLanguage( 'fr' )" ); + $this->assertSame( 'Hauptseite', $msg->inLanguage( 'de' )->plain(), "inLanguage( 'de' )" ); + $this->assertSame( 'Main Page', $msg->inContentLanguage()->plain(), "inContentLanguage()" ); + $this->assertSame( 'Accueil', $msg->inLanguage( 'fr' )->plain(), "inLanguage( 'fr' )" ); } /** @@ -538,18 +707,18 @@ class MessageTest extends MediaWikiLangTestCase { // NOTE: make sure internal caching of the message text is reset appropriately. // NOTE: wgForceUIMsgAsContentMsg forces the messages *current* language to be used. $msg = wfMessage( 'mainpage' ); - $this->assertEquals( + $this->assertSame( 'Accueil', $msg->inContentLanguage()->plain(), 'inContentLanguage() with ForceUIMsg override enabled' ); - $this->assertEquals( 'Main Page', $msg->inLanguage( 'en' )->plain(), "inLanguage( 'en' )" ); - $this->assertEquals( + $this->assertSame( 'Main Page', $msg->inLanguage( 'en' )->plain(), "inLanguage( 'en' )" ); + $this->assertSame( 'Main Page', $msg->inContentLanguage()->plain(), 'inContentLanguage() with ForceUIMsg override enabled' ); - $this->assertEquals( 'Hauptseite', $msg->inLanguage( 'de' )->plain(), "inLanguage( 'de' )" ); + $this->assertSame( 'Hauptseite', $msg->inLanguage( 'de' )->plain(), "inLanguage( 'de' )" ); } /** @@ -568,18 +737,18 @@ class MessageTest extends MediaWikiLangTestCase { $msg = new Message( 'parentheses' ); $msg->rawParams( 'foo' ); $msg->title( Title::newFromText( 'Testing' ) ); - $this->assertEquals( '(foo)', $msg->parse(), 'Sanity check' ); + $this->assertSame( '(foo)', $msg->parse(), 'Sanity check' ); $msg = unserialize( serialize( $msg ) ); - $this->assertEquals( '(foo)', $msg->parse() ); + $this->assertSame( '(foo)', $msg->parse() ); $title = TestingAccessWrapper::newFromObject( $msg )->title; $this->assertInstanceOf( 'Title', $title ); - $this->assertEquals( 'Testing', $title->getFullText() ); + $this->assertSame( 'Testing', $title->getFullText() ); $msg = new Message( 'mainpage' ); $msg->inLanguage( 'de' ); - $this->assertEquals( 'Hauptseite', $msg->plain(), 'Sanity check' ); + $this->assertSame( 'Hauptseite', $msg->plain(), 'Sanity check' ); $msg = unserialize( serialize( $msg ) ); - $this->assertEquals( 'Hauptseite', $msg->plain() ); + $this->assertSame( 'Hauptseite', $msg->plain() ); } /** @@ -589,6 +758,10 @@ class MessageTest extends MediaWikiLangTestCase { public function testNewFromSpecifier( $value, $expectedText ) { $message = Message::newFromSpecifier( $value ); $this->assertInstanceOf( Message::class, $message ); + if ( $value instanceof Message ) { + $this->assertInstanceOf( get_class( $value ), $message ); + $this->assertEquals( $value, $message ); + } $this->assertSame( $expectedText, $message->text() ); } @@ -602,8 +775,9 @@ class MessageTest extends MediaWikiLangTestCase { 'array' => [ [ 'youhavenewmessages', 'foo', 'bar' ], 'You have foo (bar).' ], 'Message' => [ new Message( 'youhavenewmessages', [ 'foo', 'bar' ] ), 'You have foo (bar).' ], 'RawMessage' => [ new RawMessage( 'foo ($1)', [ 'bar' ] ), 'foo (bar)' ], + 'ApiMessage' => [ new ApiMessage( [ 'mainpage' ], 'code', [ 'data' ] ), 'Main Page' ], 'MessageSpecifier' => [ $messageSpecifier, 'Main Page' ], + 'nested RawMessage' => [ [ new RawMessage( 'foo ($1)', [ 'bar' ] ) ], 'foo (bar)' ], ]; } } - diff --git a/tests/phpunit/includes/MimeMagicTest.php b/tests/phpunit/includes/MimeMagicTest.php deleted file mode 100644 index e00cf0ce30..0000000000 --- a/tests/phpunit/includes/MimeMagicTest.php +++ /dev/null @@ -1,51 +0,0 @@ -mimeMagic = MimeMagic::singleton(); - parent::setUp(); - } - - /** - * @dataProvider providerImproveTypeFromExtension - * @param string $ext File extension (no leading dot) - * @param string $oldMime Initially detected MIME - * @param string $expectedMime MIME type after taking extension into account - */ - function testImproveTypeFromExtension( $ext, $oldMime, $expectedMime ) { - $actualMime = $this->mimeMagic->improveTypeFromExtension( $oldMime, $ext ); - $this->assertEquals( $expectedMime, $actualMime ); - } - - function providerImproveTypeFromExtension() { - return [ - [ 'gif', 'image/gif', 'image/gif' ], - [ 'gif', 'unknown/unknown', 'unknown/unknown' ], - [ 'wrl', 'unknown/unknown', 'model/vrml' ], - [ 'txt', 'text/plain', 'text/plain' ], - [ 'csv', 'text/plain', 'text/csv' ], - [ 'tsv', 'text/plain', 'text/tab-separated-values' ], - [ 'js', 'text/javascript', 'application/javascript' ], - [ 'js', 'application/x-javascript', 'application/javascript' ], - [ 'json', 'text/plain', 'application/json' ], - [ 'foo', 'application/x-opc+zip', 'application/zip' ], - [ 'docx', 'application/x-opc+zip', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ], - [ 'djvu', 'image/x-djvu', 'image/vnd.djvu' ], - [ 'wav', 'audio/wav', 'audio/wav' ], - ]; - } - - /** - * Test to make sure that encoder=ffmpeg2theora doesn't trigger - * MEDIATYPE_VIDEO (bug 63584) - */ - function testOggRecognize() { - $oggFile = __DIR__ . '/../data/media/say-test.ogg'; - $actualType = $this->mimeMagic->getMediaType( $oggFile, 'application/ogg' ); - $this->assertEquals( $actualType, MEDIATYPE_AUDIO ); - } -} diff --git a/tests/phpunit/includes/OutputPageTest.php b/tests/phpunit/includes/OutputPageTest.php index 8d4a34775c..626987209d 100644 --- a/tests/phpunit/includes/OutputPageTest.php +++ b/tests/phpunit/includes/OutputPageTest.php @@ -139,79 +139,40 @@ class OutputPageTest extends MediaWikiTestCase { public static function provideMakeResourceLoaderLink() { // @codingStandardsIgnoreStart Generic.Files.LineLength return [ - // Load module script only + // Single only=scripts load [ [ 'test.foo', ResourceLoaderModule::TYPE_SCRIPTS ], "" ], - [ - // Don't condition wrap raw modules (like the startup module) - [ 'test.raw', ResourceLoaderModule::TYPE_SCRIPTS ], - '' - ], - // Load module styles only - // This also tests the order the modules are put into the url + // Multiple only=styles load [ [ [ 'test.baz', 'test.foo', 'test.bar' ], ResourceLoaderModule::TYPE_STYLES ], - '' + '' ], - // Load private module (only=scripts) + // Private embed (only=scripts) [ [ 'test.quux', ResourceLoaderModule::TYPE_SCRIPTS ], "" ], - // Load private module (combined) - [ - [ 'test.quux', ResourceLoaderModule::TYPE_COMBINED ], - "" - ], - // Load no modules - [ - [ [], ResourceLoaderModule::TYPE_COMBINED ], - '', - ], - // noscript group - [ - [ 'test.noscript', ResourceLoaderModule::TYPE_STYLES ], - '' - ], - // Load two modules in separate groups - [ - [ [ 'test.group.foo', 'test.group.bar' ], ResourceLoaderModule::TYPE_COMBINED ], - "\n" - . "" - ], ]; // @codingStandardsIgnoreEnd } /** + * See ResourceLoaderClientHtmlTest for full coverage. + * * @dataProvider provideMakeResourceLoaderLink * @covers OutputPage::makeResourceLoaderLink - * @covers ResourceLoader::makeLoaderImplementScript - * @covers ResourceLoader::makeModuleResponse - * @covers ResourceLoader::makeInlineScript - * @covers ResourceLoader::makeLoaderStateScript - * @covers ResourceLoader::createLoaderURL */ public function testMakeResourceLoaderLink( $args, $expectedHtml ) { $this->setMwGlobals( [ 'wgResourceLoaderDebug' => false, 'wgLoadScript' => 'http://127.0.0.1:8080/w/load.php', - // Affects whether CDATA is inserted - 'wgWellFormedXml' => false, ] ); $class = new ReflectionClass( 'OutputPage' ); $method = $class->getMethod( 'makeResourceLoaderLink' ); @@ -240,25 +201,9 @@ class OutputPageTest extends MediaWikiTestCase { 'styles' => '/* pref-animate=off */ .mw-icon { transition: none; }', 'group' => 'private', ] ), - 'test.raw' => new ResourceLoaderTestModule( [ - 'script' => 'mw.test.baz( { token: 123 } );', - 'isRaw' => true, - ] ), - 'test.noscript' => new ResourceLoaderTestModule( [ - 'styles' => '.mw-test-noscript { content: "style"; }', - 'group' => 'noscript', - ] ), - 'test.group.bar' => new ResourceLoaderTestModule( [ - 'styles' => '.mw-group-bar { content: "style"; }', - 'group' => 'bar', - ] ), - 'test.group.foo' => new ResourceLoaderTestModule( [ - 'styles' => '.mw-group-foo { content: "style"; }', - 'group' => 'foo', - ] ), ] ); $links = $method->invokeArgs( $out, $args ); - $actualHtml = implode( "\n", $links['html'] ); + $actualHtml = strval( $links ); $this->assertEquals( $expectedHtml, $actualHtml ); } @@ -360,6 +305,37 @@ class OutputPageTest extends MediaWikiTestCase { $request->setCookie( 'Token', '123' ); $this->assertTrue( $outputPage->haveCacheVaryCookies() ); } + + /* + * @covers OutputPage::addCategoryLinks + * @covers OutputPage::getCategories + */ + function testGetCategories() { + $fakeResultWrapper = new FakeResultWrapper( [ + (object) [ + 'pp_value' => 1, + 'page_title' => 'Test' + ], + (object) [ + 'page_title' => 'Test2' + ] + ] ); + $outputPage = $this->getMockBuilder( 'OutputPage' ) + ->setConstructorArgs( [ new RequestContext() ] ) + ->setMethods( [ 'addCategoryLinksToLBAndGetResult' ] ) + ->getMock(); + $outputPage->expects( $this->any() ) + ->method( 'addCategoryLinksToLBAndGetResult' ) + ->will( $this->returnValue( $fakeResultWrapper ) ); + + $outputPage->addCategoryLinks( [ + 'Test' => 'Test', + 'Test2' => 'Test2', + ] ); + $this->assertEquals( [ 0 => 'Test', '1' => 'Test2' ], $outputPage->getCategories() ); + $this->assertEquals( [ 0 => 'Test2' ], $outputPage->getCategories( 'normal' ) ); + $this->assertEquals( [ 0 => 'Test' ], $outputPage->getCategories( 'hidden' ) ); + } } /** diff --git a/tests/phpunit/includes/PagePropsTest.php b/tests/phpunit/includes/PagePropsTest.php index cc1708a2b4..29c9e228f2 100644 --- a/tests/phpunit/includes/PagePropsTest.php +++ b/tests/phpunit/includes/PagePropsTest.php @@ -266,11 +266,9 @@ class TestPageProps extends MediaWikiLangTestCase { } protected function setProperties( $pageID, $properties ) { - $rows = []; foreach ( $properties as $propertyName => $propertyValue ) { - $row = [ 'pp_page' => $pageID, 'pp_propname' => $propertyName, @@ -295,11 +293,9 @@ class TestPageProps extends MediaWikiLangTestCase { } protected function setProperty( $pageID, $propertyName, $propertyValue ) { - $properties = []; $properties[$propertyName] = $propertyValue; $this->setProperties( $pageID, $properties ); - } } diff --git a/tests/phpunit/includes/PrefixSearchTest.php b/tests/phpunit/includes/PrefixSearchTest.php index 0ec200c163..c5a7e04e30 100644 --- a/tests/phpunit/includes/PrefixSearchTest.php +++ b/tests/phpunit/includes/PrefixSearchTest.php @@ -2,8 +2,11 @@ /** * @group Search * @group Database + * @covers PrefixSearch */ class PrefixSearchTest extends MediaWikiLangTestCase { + const NS_NONCAP = 12346; + private $originalHandlers; public function addDBDataOnce() { @@ -31,6 +34,10 @@ class PrefixSearchTest extends MediaWikiLangTestCase { $this->insertPage( 'Talk:Example' ); $this->insertPage( 'User:Example' ); + + $this->insertPage( Title::makeTitle( self::NS_NONCAP, 'Bar' ) ); + $this->insertPage( Title::makeTitle( self::NS_NONCAP, 'Upper' ) ); + $this->insertPage( Title::makeTitle( self::NS_NONCAP, 'sandbox' ) ); } protected function setUp() { @@ -44,11 +51,17 @@ class PrefixSearchTest extends MediaWikiLangTestCase { $this->setMwGlobals( [ 'wgSpecialPages' => [], 'wgHooks' => [], + 'wgExtraNamespaces' => [ self::NS_NONCAP => 'NonCap' ], + 'wgCapitalLinkOverrides' => [ self::NS_NONCAP => false ], ] ); $this->originalHandlers = TestingAccessWrapper::newFromClass( 'Hooks' )->handlers; TestingAccessWrapper::newFromClass( 'Hooks' )->handlers = []; + // Clear caches so that our new namespace appears + MWNamespace::getCanonicalNamespaces( true ); + Language::factory( 'en' )->resetNamespaces(); + SpecialPageFactory::resetList(); } @@ -116,11 +129,11 @@ class PrefixSearchTest extends MediaWikiLangTestCase { 'results' => [ 'Special:ActiveUsers', 'Special:AllMessages', - 'Special:AllMyFiles', + 'Special:AllMyUploads', ], // Third result when testing offset 'offsetresult' => [ - 'Special:AllMyUploads', + 'Special:AllPages', ], ] ], [ [ @@ -133,7 +146,7 @@ class PrefixSearchTest extends MediaWikiLangTestCase { ], // Third result when testing offset 'offsetresult' => [ - 'Special:UncategorizedImages', + 'Special:UncategorizedPages', ], ] ], [ [ @@ -158,6 +171,29 @@ class PrefixSearchTest extends MediaWikiLangTestCase { 'Special:EditWatchlist/clear', ], ] ], + [ [ + 'Namespace with case sensitive first letter', + 'query' => 'NonCap:upper', + 'results' => [] + ] ], + [ [ + 'Multinamespace search', + 'query' => 'B', + 'results' => [ + 'Bar', + 'NonCap:Bar', + ], + 'namespaces' => [ NS_MAIN, self::NS_NONCAP ], + ] ], + [ [ + 'Multinamespace search with lowercase first letter', + 'query' => 'sand', + 'results' => [ + 'Sandbox', + 'NonCap:sandbox', + ], + 'namespaces' => [ NS_MAIN, self::NS_NONCAP ], + ] ], ]; } @@ -168,8 +204,11 @@ class PrefixSearchTest extends MediaWikiLangTestCase { */ public function testSearch( array $case ) { $this->searchProvision( null ); + + $namespaces = isset( $case['namespaces'] ) ? $case['namespaces'] : []; + $searcher = new StringPrefixSearch; - $results = $searcher->search( $case['query'], 3 ); + $results = $searcher->search( $case['query'], 3, $namespaces ); $this->assertEquals( $case['results'], $results, @@ -184,8 +223,11 @@ class PrefixSearchTest extends MediaWikiLangTestCase { */ public function testSearchWithOffset( array $case ) { $this->searchProvision( null ); + + $namespaces = isset( $case['namespaces'] ) ? $case['namespaces'] : []; + $searcher = new StringPrefixSearch; - $results = $searcher->search( $case['query'], 3, [], 1 ); + $results = $searcher->search( $case['query'], 3, $namespaces, 1 ); // We don't expect the first result when offsetting array_shift( $case['results'] ); diff --git a/tests/phpunit/includes/SampleTest.php b/tests/phpunit/includes/SampleTest.php index 7c313845f9..02935a5369 100644 --- a/tests/phpunit/includes/SampleTest.php +++ b/tests/phpunit/includes/SampleTest.php @@ -32,7 +32,7 @@ class TestSample extends MediaWikiLangTestCase { * they run. While MediaWiki isn't strictly an Agile Programming * project, you are encouraged to use the naming described under * "Agile Documentation" at - * http://www.phpunit.de/manual/3.4/en/other-uses-for-tests.html + * https://www.phpunit.de/manual/3.4/en/other-uses-for-tests.html */ public function testTitleObjectStringConversion() { $title = Title::newFromText( "text" ); @@ -45,7 +45,7 @@ class TestSample extends MediaWikiLangTestCase { /** * If you want to run a the same test with a variety of data, use a data provider. - * see: http://www.phpunit.de/manual/3.4/en/writing-tests-for-phpunit.html + * see: https://www.phpunit.de/manual/3.4/en/writing-tests-for-phpunit.html */ public static function provideTitles() { return [ @@ -60,7 +60,7 @@ class TestSample extends MediaWikiLangTestCase { // @codingStandardsIgnoreStart Generic.Files.LineLength /** * @dataProvider provideTitles - * See http://phpunit.de/manual/3.7/en/appendixes.annotations.html#appendixes.annotations.dataProvider + * See https://phpunit.de/manual/3.7/en/appendixes.annotations.html#appendixes.annotations.dataProvider */ // @codingStandardsIgnoreEnd public function testCreateBasicListOfTitles( $titleName, $ns, $text ) { @@ -89,7 +89,7 @@ class TestSample extends MediaWikiLangTestCase { /** * @depends testSetUpMainPageTitleForNextTest - * See http://phpunit.de/manual/3.7/en/appendixes.annotations.html#appendixes.annotations.depends + * See https://phpunit.de/manual/3.7/en/appendixes.annotations.html#appendixes.annotations.depends */ public function testCheckMainPageTitleIsConsideredLocal( $title ) { $this->assertTrue( $title->isLocal() ); @@ -98,7 +98,7 @@ class TestSample extends MediaWikiLangTestCase { // @codingStandardsIgnoreStart Generic.Files.LineLength /** * @expectedException InvalidArgumentException - * See http://phpunit.de/manual/3.7/en/appendixes.annotations.html#appendixes.annotations.expectedException + * See https://phpunit.de/manual/3.7/en/appendixes.annotations.html#appendixes.annotations.expectedException */ // @codingStandardsIgnoreEnd public function testTitleObjectFromObject() { diff --git a/tests/phpunit/includes/SanitizerTest.php b/tests/phpunit/includes/SanitizerTest.php index 72d71667b2..12db1a198f 100644 --- a/tests/phpunit/includes/SanitizerTest.php +++ b/tests/phpunit/includes/SanitizerTest.php @@ -134,19 +134,19 @@ class SanitizerTest extends MediaWikiTestCase { 'Self-closing closing div' ], // Make sure special nested HTML5 semantics are not broken - // http://www.whatwg.org/html/text-level-semantics.html#the-kbd-element + // https://html.spec.whatwg.org/multipage/semantics.html#the-kbd-element [ 'Shift+F3', 'Shift+F3', 'Nested .' ], - // http://www.whatwg.org/html/text-level-semantics.html#the-sub-and-sup-elements + // https://html.spec.whatwg.org/multipage/semantics.html#the-sub-and-sup-elements [ 'xi, yi', 'xi, yi', 'Nested .' ], - // http://www.whatwg.org/html/text-level-semantics.html#the-dfn-element + // https://html.spec.whatwg.org/multipage/semantics.html#the-dfn-element [ 'GDO', 'GDO', @@ -248,7 +248,7 @@ class SanitizerTest extends MediaWikiTestCase { } public static function provideDeprecatedAttributes() { - /** array( , , [message] ) */ + /** [ , , [message] ] */ return [ [ 'clear="left"', 'br' ], [ 'clear="all"', 'br' ], @@ -276,7 +276,7 @@ class SanitizerTest extends MediaWikiTestCase { } public static function provideCssCommentsFixtures() { - /** array( , , [message] ) */ + /** [ , , [message] ] */ return [ // Valid comments spanning entire input [ '/**/', '/**/' ], @@ -314,6 +314,8 @@ class SanitizerTest extends MediaWikiTestCase { '/* insecure input */', 'background-image: -moz-image-set("asdf.png" 1x, "asdf.png" 2x);' ], + [ '/* insecure input */', 'foo: attr( title, url );' ], + [ '/* insecure input */', 'foo: attr( title url );' ], ]; } @@ -353,7 +355,7 @@ class SanitizerTest extends MediaWikiTestCase { } public static function provideEscapeIdReferenceList() { - /** array( , , ) */ + /** [ , , ] */ return [ [ 'foo bar', 'foo', 'bar' ], [ '#1 #2', '#1', '#2' ], diff --git a/tests/phpunit/includes/Services/ServiceContainerTest.php b/tests/phpunit/includes/Services/ServiceContainerTest.php index 933777c33c..f22e123856 100644 --- a/tests/phpunit/includes/Services/ServiceContainerTest.php +++ b/tests/phpunit/includes/Services/ServiceContainerTest.php @@ -166,6 +166,55 @@ class ServiceContainerTest extends PHPUnit_Framework_TestCase { $this->assertSame( 'Bar!', $services->getService( 'Bar' ) ); } + public function testImportWiring() { + $services = $this->newServiceContainer(); + + $wiring = [ + 'Foo' => function() { + return 'Foo!'; + }, + 'Bar' => function() { + return 'Bar!'; + }, + 'Car' => function() { + return 'FUBAR!'; + }, + ]; + + $services->applyWiring( $wiring ); + + $newServices = $this->newServiceContainer(); + + // define a service before importing, so we can later check that + // existing service instances survive importWiring() + $newServices->defineService( 'Car', function() { + return 'Car!'; + } ); + + // force instantiation + $newServices->getService( 'Car' ); + + // Define another service, so we can later check that extra wiring + // is not lost. + $newServices->defineService( 'Xar', function() { + return 'Xar!'; + } ); + + // import wiring, but skip `Bar` + $newServices->importWiring( $services, [ 'Bar' ] ); + + $this->assertNotContains( 'Bar', $newServices->getServiceNames(), 'Skip `Bar` service' ); + $this->assertSame( 'Foo!', $newServices->getService( 'Foo' ) ); + + // import all wiring, but preserve existing service instance + $newServices->importWiring( $services ); + + $this->assertContains( 'Bar', $newServices->getServiceNames(), 'Import all services' ); + $this->assertSame( 'Bar!', $newServices->getService( 'Bar' ) ); + $this->assertSame( 'Car!', $newServices->getService( 'Car' ), 'Use existing service instance' ); + $this->assertSame( 'Xar!', $newServices->getService( 'Xar' ), 'Predefined services are kept' ); + } + public function testLoadWiringFiles() { $services = $this->newServiceContainer(); @@ -220,6 +269,27 @@ class ServiceContainerTest extends PHPUnit_Framework_TestCase { $this->assertSame( $theService1, $services->getService( $name ) ); } + public function testRedefineService_disabled() { + $services = $this->newServiceContainer( [ 'Foo' ] ); + + $theService1 = new stdClass(); + $name = 'TestService92834576'; + + $services->defineService( $name, function() { + return 'Foo'; + } ); + + // disable the service. we should be able to redefine it anyway. + $services->disableService( $name ); + + $services->redefineService( $name, function() use ( $theService1 ) { + return $theService1; + } ); + + // force instantiation, check result + $this->assertSame( $theService1, $services->getService( $name ) ); + } + public function testRedefineService_fail_undefined() { $services = $this->newServiceContainer(); @@ -294,13 +364,6 @@ class ServiceContainerTest extends PHPUnit_Framework_TestCase { $this->assertContains( 'Bar', $services->getServiceNames() ); $this->assertContains( 'Qux', $services->getServiceNames() ); - // re-enable Bar service - $services->redefineService( 'Bar', function() { - return new stdClass(); - } ); - - $services->getService( 'Bar' ); - $this->setExpectedException( 'MediaWiki\Services\ServiceDisabledException' ); $services->getService( 'Qux' ); } diff --git a/tests/phpunit/includes/StatusTest.php b/tests/phpunit/includes/StatusTest.php index 782fab0c6b..7e56ebf20b 100644 --- a/tests/phpunit/includes/StatusTest.php +++ b/tests/phpunit/includes/StatusTest.php @@ -57,9 +57,11 @@ class StatusTest extends MediaWikiLangTestCase { } /** + * Test 'ok' and 'errors' getters. * + * @covers Status::__get */ - public function testOkAndErrors() { + public function testOkAndErrorsGetters() { $status = Status::newGood( 'foo' ); $this->assertTrue( $status->ok ); $status = Status::newFatal( 'foo', 1, 2 ); @@ -76,6 +78,19 @@ class StatusTest extends MediaWikiLangTestCase { ); } + /** + * Test 'ok' setter. + * + * @covers Status::__set + */ + public function testOkSetter() { + $status = new Status(); + $status->ok = false; + $this->assertFalse( $status->isOK() ); + $status->ok = true; + $this->assertTrue( $status->isOK() ); + } + /** * @dataProvider provideSetResult * @covers Status::setResult @@ -98,11 +113,12 @@ class StatusTest extends MediaWikiLangTestCase { /** * @dataProvider provideIsOk - * @covers Status::isOk + * @covers Status::setOK + * @covers Status::isOK */ public function testIsOk( $ok ) { $status = new Status(); - $status->ok = $ok; + $status->setOK( $ok ); $this->assertEquals( $ok, $status->isOK() ); } @@ -128,7 +144,7 @@ class StatusTest extends MediaWikiLangTestCase { */ public function testIsGood( $ok, $errors, $expected ) { $status = new Status(); - $status->ok = $ok; + $status->setOK( $ok ); foreach ( $errors as $error ) { $status->warning( $error ); } @@ -171,6 +187,7 @@ class StatusTest extends MediaWikiLangTestCase { * @covers Status::error * @covers Status::getErrorsArray * @covers Status::getStatusArray + * @covers Status::getErrors */ public function testErrorWithMessage( $mockDetails ) { $status = new Status(); @@ -361,7 +378,7 @@ class StatusTest extends MediaWikiLangTestCase { ]; $status = new Status(); - $status->ok = false; + $status->setOK( false ); $testCases['GoodButNoError'] = [ $status, "Internal error: Status::getWikiText: Invalid result object: no error text but not OK\n", @@ -376,9 +393,9 @@ class StatusTest extends MediaWikiLangTestCase { $status->warning( 'fooBar!' ); $testCases['1StringWarning'] = [ $status, - "", + "⧼fooBar!⧽", "(wrap-short: (fooBar!))", - "

    <fooBar!>\n

    ", + "

    ⧼fooBar!⧽\n

    ", "

    (wrap-short: (fooBar!))\n

    ", ]; @@ -387,9 +404,9 @@ class StatusTest extends MediaWikiLangTestCase { $status->warning( 'fooBar2!' ); $testCases['2StringWarnings'] = [ $status, - "* \n* \n", + "* ⧼fooBar!⧽\n* ⧼fooBar2!⧽\n", "(wrap-long: * (fooBar!)\n* (fooBar2!)\n)", - "
    • <fooBar!>
    • \n
    • <fooBar2!>
    \n", + "
    • ⧼fooBar!⧽
    • \n
    • ⧼fooBar2!⧽
    \n", "

    (wrap-long: * (fooBar!)\n

    \n
    • (fooBar2!)
    \n

    )\n

    ", ]; @@ -397,9 +414,9 @@ class StatusTest extends MediaWikiLangTestCase { $status->warning( new Message( 'fooBar!', [ 'foo', 'bar' ] ) ); $testCases['1MessageWarning'] = [ $status, - "", + "⧼fooBar!⧽", "(wrap-short: (fooBar!: foo, bar))", - "

    <fooBar!>\n

    ", + "

    ⧼fooBar!⧽\n

    ", "

    (wrap-short: (fooBar!: foo, bar))\n

    ", ]; @@ -408,9 +425,9 @@ class StatusTest extends MediaWikiLangTestCase { $status->warning( new Message( 'fooBar2!' ) ); $testCases['2MessageWarnings'] = [ $status, - "* \n* \n", + "* ⧼fooBar!⧽\n* ⧼fooBar2!⧽\n", "(wrap-long: * (fooBar!: foo, bar)\n* (fooBar2!)\n)", - "
    • <fooBar!>
    • \n
    • <fooBar2!>
    \n", + "
    • ⧼fooBar!⧽
    • \n
    • ⧼fooBar2!⧽
    \n", "

    (wrap-long: * (fooBar!: foo, bar)\n

    \n
    • (fooBar2!)
    \n

    )\n

    ", ]; @@ -475,7 +492,7 @@ class StatusTest extends MediaWikiLangTestCase { ]; $status = new Status(); - $status->ok = false; + $status->setOK( false ); $testCases['GoodButNoError'] = [ $status, [ "Status::getMessage: Invalid result object: no error text but not OK\n" ], @@ -645,4 +662,66 @@ class StatusTest extends MediaWikiLangTestCase { ]; } + /** + * @dataProvider provideErrorsWarningsOnly + * @covers Status::splitByErrorType + * @covers StatusValue::splitByErrorType + */ + public function testGetErrorsWarningsOnlyStatus( $errorText, $warningText, $type, $errorResult, + $warningResult + ) { + $status = Status::newGood(); + if ( $errorText ) { + $status->fatal( $errorText ); + } + if ( $warningText ) { + $status->warning( $warningText ); + } + $testStatus = $status->splitByErrorType()[$type]; + $this->assertEquals( $errorResult, $testStatus->getErrorsByType( 'error' ) ); + $this->assertEquals( $warningResult, $testStatus->getErrorsByType( 'warning' ) ); + } + + public static function provideErrorsWarningsOnly() { + return [ + [ + 'Just an error', + 'Just a warning', + 0, + [ + 0 => [ + 'type' => 'error', + 'message' => 'Just an error', + 'params' => [] + ], + ], + [], + ], [ + 'Just an error', + 'Just a warning', + 1, + [], + [ + 0 => [ + 'type' => 'warning', + 'message' => 'Just a warning', + 'params' => [] + ], + ], + ], [ + null, + null, + 1, + [], + [], + ], [ + null, + null, + 0, + [], + [], + ] + ]; + } + } diff --git a/tests/phpunit/includes/TemplateCategoriesTest.php b/tests/phpunit/includes/TemplateCategoriesTest.php index 9359568375..152602ac5e 100644 --- a/tests/phpunit/includes/TemplateCategoriesTest.php +++ b/tests/phpunit/includes/TemplateCategoriesTest.php @@ -91,6 +91,5 @@ class TemplateCategoriesTest extends MediaWikiLangTestCase { $title->getParentCategories(), 'Verify that the page is no longer in the category after template deletion' ); - } } diff --git a/tests/phpunit/includes/TestUser.php b/tests/phpunit/includes/TestUser.php index 142c77f932..86f4ae789d 100644 --- a/tests/phpunit/includes/TestUser.php +++ b/tests/phpunit/includes/TestUser.php @@ -6,25 +6,19 @@ */ class TestUser { /** - * @deprecated Since 1.25. Use TestUser::getUser()->getName() - * @private * @var string */ - public $username; + private $username; /** - * @deprecated Since 1.25. Use TestUser::getPassword() - * @private * @var string */ - public $password; + private $password; /** - * @deprecated Since 1.25. Use TestUser::getUser() - * @private * @var User */ - public $user; + private $user; private function assertNotReal() { global $wgDBprefix; @@ -78,6 +72,12 @@ class TestUser { $this->user->removeGroup( $group ); } if ( $change ) { + // Disable CAS check before saving. The User object may have been initialized from cached + // information that may be out of whack with the database during testing. If tests were + // perfectly isolated, this would not happen. But if it does happen, let's just ignore the + // inconsistency, and just write the data we want - during testing, we are not worried + // about data loss. + $this->user->mTouched = ''; $this->user->saveSettings(); } } @@ -129,17 +129,28 @@ class TestUser { throw new MWException( "Passed User has not been added to the database yet!" ); } - $passwordFactory = new PasswordFactory(); - $passwordFactory->init( RequestContext::getMain()->getConfig() ); - // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only - $passwordFactory->setDefaultType( 'A' ); - $pwhash = $passwordFactory->newFromPlaintext( $password ); - wfGetDB( DB_MASTER )->update( + $dbw = wfGetDB( DB_MASTER ); + $row = $dbw->selectRow( 'user', - [ 'user_password' => $pwhash->toString() ], + [ 'user_password' ], [ 'user_id' => $user->getId() ], __METHOD__ ); + if ( !$row ) { + throw new MWException( "Passed User has an ID but is not in the database?" ); + } + + $passwordFactory = new PasswordFactory(); + $passwordFactory->init( RequestContext::getMain()->getConfig() ); + if ( !$passwordFactory->newFromCiphertext( $row->user_password )->equals( $password ) ) { + $passwordHash = $passwordFactory->newFromPlaintext( $password ); + $dbw->update( + 'user', + [ 'user_password' => $passwordHash->toString() ], + [ 'user_id' => $user->getId() ], + __METHOD__ + ); + } } /** diff --git a/tests/phpunit/includes/TestUserRegistry.php b/tests/phpunit/includes/TestUserRegistry.php new file mode 100644 index 0000000000..4818b49a34 --- /dev/null +++ b/tests/phpunit/includes/TestUserRegistry.php @@ -0,0 +1,110 @@ + TestUser) */ + private static $testUsers = []; + + /** @var int Count of users that have been generated */ + private static $counter = 0; + + /** @var int Random int, included in IDs */ + private static $randInt; + + public static function getNextId() { + if ( !self::$randInt ) { + self::$randInt = mt_rand( 1, 0xFFFFFF ); + } + return sprintf( '%06x.%03x', self::$randInt, ++self::$counter ); + } + + /** + * Get a TestUser object that the caller may modify. + * + * @since 1.28 + * + * @param string $testName Caller's __CLASS__. Used to generate the + * user's username. + * @param string[] $groups Groups the test user should be added to. + * @return TestUser + */ + public static function getMutableTestUser( $testName, $groups = [] ) { + $id = self::getNextId(); + $password = wfRandomString( 20 ); + $testUser = new TestUser( + "TestUser $testName $id", // username + "Name $id", // real name + "$id@mediawiki.test", // e-mail + $groups, // groups + $password // password + ); + $testUser->getUser()->clearInstanceCache(); + return $testUser; + } + + /** + * Get a TestUser object that the caller may not modify. + * + * Whenever possible, unit tests should use immutable users, because + * immutable users can be reused in multiple tests, which helps keep + * the unit tests fast. + * + * @since 1.28 + * + * @param string[] $groups Groups the test user should be added to. + * @return TestUser + */ + public static function getImmutableTestUser( $groups = [] ) { + $groups = array_unique( $groups ); + sort( $groups ); + $key = implode( ',', $groups ); + + $testUser = isset( self::$testUsers[$key] ) + ? self::$testUsers[$key] + : false; + + if ( !$testUser || !$testUser->getUser()->isLoggedIn() ) { + $id = self::getNextId(); + // Hack! If this is the primary sysop account, make the username + // be 'UTSysop', for back-compat, and for the sake of PHPUnit data + // provider methods, which are executed before the test database + // is set up. See T136348. + if ( $groups === [ 'bureaucrat', 'sysop' ] ) { + $username = 'UTSysop'; + $password = 'UTSysopPassword'; + } else { + $username = "TestUser $id"; + $password = wfRandomString( 20 ); + } + self::$testUsers[$key] = $testUser = new TestUser( + $username, // username + "Name $id", // real name + "$id@mediawiki.test", // e-mail + $groups, // groups + $password // password + ); + } + + $testUser->getUser()->clearInstanceCache(); + return self::$testUsers[$key]; + } + + /** + * Clear the registry. + * + * TestUsers created by this class will not be deleted, but any handles + * to existing immutable TestUsers will be deleted, ensuring these users + * are not reused. We don't reset the counter or random string by design. + * + * @since 1.28 + * + * @param string[] $groups Groups the test user should be added to. + * @return TestUser + */ + public static function clear() { + self::$testUsers = []; + } +} diff --git a/tests/phpunit/includes/TitleTest.php b/tests/phpunit/includes/TitleTest.php index 7d025d288f..7925c6f8f9 100644 --- a/tests/phpunit/includes/TitleTest.php +++ b/tests/phpunit/includes/TitleTest.php @@ -90,6 +90,8 @@ class TitleTest extends MediaWikiTestCase { [ 'A < B', 'title-invalid-characters' ], [ 'A > B', 'title-invalid-characters' ], [ 'A | B', 'title-invalid-characters' ], + [ "A \t B", 'title-invalid-characters' ], + [ "A \n B", 'title-invalid-characters' ], // URL encoding [ 'A%20B', 'title-invalid-characters' ], [ 'A%23B', 'title-invalid-characters' ], @@ -144,6 +146,13 @@ class TitleTest extends MediaWikiTestCase { ] ] ] ); + + // Reset TitleParser since we modified $wgLocalInterwikis + $this->setService( 'TitleParser', new MediaWikiTitleCodec( + Language::factory( 'en' ), + new GenderCache(), + [ 'localtestiw' ] + ) ); } /** @@ -702,4 +711,42 @@ class TitleTest extends MediaWikiTestCase { $this->assertEquals( $title->getInterwiki(), $fragmentTitle->getInterwiki() ); $this->assertEquals( $fragment, $fragmentTitle->getFragment() ); } + + public function provideGetPrefixedText() { + return [ + // ns = 0 + [ + Title::makeTitle( NS_MAIN, 'Foobar' ), + 'Foobar' + ], + // ns = 2 + [ + Title::makeTitle( NS_USER, 'Foobar' ), + 'User:Foobar' + ], + // fragment not included + [ + Title::makeTitle( NS_MAIN, 'Foobar', 'fragment' ), + 'Foobar' + ], + // ns = -2 + [ + Title::makeTitle( NS_MEDIA, 'Foobar' ), + 'Media:Foobar' + ], + // non-existent namespace + [ + Title::makeTitle( 100000, 'Foobar' ), + ':Foobar' + ], + ]; + } + + /** + * @covers Title::getPrefixedText + * @dataProvider provideGetPrefixedText + */ + public function testGetPrefixedText( Title $title, $expected ) { + $this->assertEquals( $expected, $title->getPrefixedText() ); + } } diff --git a/tests/phpunit/includes/WatchedItemIntegrationTest.php b/tests/phpunit/includes/WatchedItemIntegrationTest.php index e5362053fd..65a8c86bb7 100644 --- a/tests/phpunit/includes/WatchedItemIntegrationTest.php +++ b/tests/phpunit/includes/WatchedItemIntegrationTest.php @@ -1,4 +1,5 @@ hideDeprecated( 'WatchedItem::fromUserTitle' ); + $this->hideDeprecated( 'WatchedItem::addWatch' ); + $this->hideDeprecated( 'WatchedItem::removeWatch' ); + $this->hideDeprecated( 'WatchedItem::isWatched' ); + $this->hideDeprecated( 'WatchedItem::duplicateEntries' ); + $this->hideDeprecated( 'WatchedItem::batchAddWatch' ); } private function getUser() { @@ -20,6 +28,7 @@ class WatchedItemIntegrationTest extends MediaWikiTestCase { } public function testWatchAndUnWatchItem() { + $user = $this->getUser(); $title = Title::newFromText( 'WatchedItemIntegrationTestPage' ); // Cleanup after previous tests @@ -54,7 +63,9 @@ class WatchedItemIntegrationTest extends MediaWikiTestCase { WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() ); - WatchedItem::fromUserTitle( $user, $title )->resetNotificationTimestamp(); + MediaWikiServices::getInstance()->getWatchedItemStore()->resetNotificationTimestamp( + $user, $title + ); $this->assertNull( WatchedItem::fromUserTitle( $user, $title )->getNotificationTimestamp() ); } @@ -98,7 +109,9 @@ class WatchedItemIntegrationTest extends MediaWikiTestCase { $user = $this->getUser(); $title = Title::newFromText( 'WatchedItemIntegrationTestPage' ); WatchedItem::fromUserTitle( $user, $title )->addWatch(); - WatchedItem::fromUserTitle( $user, $title )->resetNotificationTimestamp(); + MediaWikiServices::getInstance()->getWatchedItemStore()->resetNotificationTimestamp( + $user, $title + ); $this->assertEquals( null, diff --git a/tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php b/tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php new file mode 100644 index 0000000000..93687df2d5 --- /dev/null +++ b/tests/phpunit/includes/WatchedItemQueryServiceUnitTest.php @@ -0,0 +1,1534 @@ +getMockBuilder( Database::class ) + ->disableOriginalConstructor() + ->getMock(); + + $mock->expects( $this->any() ) + ->method( 'makeList' ) + ->with( + $this->isType( 'array' ), + $this->isType( 'int' ) + ) + ->will( $this->returnCallback( function( $a, $conj ) { + $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR '; + return join( $sqlConj, array_map( function( $s ) { + return '(' . $s . ')'; + }, $a + ) ); + } ) ); + + $mock->expects( $this->any() ) + ->method( 'addQuotes' ) + ->will( $this->returnCallback( function( $value ) { + return "'$value'"; + } ) ); + + $mock->expects( $this->any() ) + ->method( 'timestamp' ) + ->will( $this->returnArgument( 0 ) ); + + $mock->expects( $this->any() ) + ->method( 'bitAnd' ) + ->willReturnCallback( function( $a, $b ) { + return "($a & $b)"; + } ); + + return $mock; + } + + /** + * @param $mockDb + * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer + */ + private function getMockLoadBalancer( $mockDb ) { + $mock = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects( $this->any() ) + ->method( 'getConnectionRef' ) + ->with( DB_SLAVE ) + ->will( $this->returnValue( $mockDb ) ); + return $mock; + } + + /** + * @param int $id + * @return PHPUnit_Framework_MockObject_MockObject|User + */ + private function getMockNonAnonUserWithId( $id ) { + $mock = $this->getMock( User::class ); + $mock->expects( $this->any() ) + ->method( 'isAnon' ) + ->will( $this->returnValue( false ) ); + $mock->expects( $this->any() ) + ->method( 'getId' ) + ->will( $this->returnValue( $id ) ); + return $mock; + } + + /** + * @param int $id + * @return PHPUnit_Framework_MockObject_MockObject|User + */ + private function getMockUnrestrictedNonAnonUserWithId( $id ) { + $mock = $this->getMockNonAnonUserWithId( $id ); + $mock->expects( $this->any() ) + ->method( 'isAllowed' ) + ->will( $this->returnValue( true ) ); + $mock->expects( $this->any() ) + ->method( 'isAllowedAny' ) + ->will( $this->returnValue( true ) ); + $mock->expects( $this->any() ) + ->method( 'useRCPatrol' ) + ->will( $this->returnValue( true ) ); + return $mock; + } + + /** + * @param int $id + * @param string $notAllowedAction + * @return PHPUnit_Framework_MockObject_MockObject|User + */ + private function getMockNonAnonUserWithIdAndRestrictedPermissions( $id, $notAllowedAction ) { + $mock = $this->getMockNonAnonUserWithId( $id ); + + $mock->expects( $this->any() ) + ->method( 'isAllowed' ) + ->will( $this->returnCallback( function( $action ) use ( $notAllowedAction ) { + return $action !== $notAllowedAction; + } ) ); + $mock->expects( $this->any() ) + ->method( 'isAllowedAny' ) + ->will( $this->returnCallback( function() use ( $notAllowedAction ) { + $actions = func_get_args(); + return !in_array( $notAllowedAction, $actions ); + } ) ); + + return $mock; + } + + /** + * @param int $id + * @return PHPUnit_Framework_MockObject_MockObject|User + */ + private function getMockNonAnonUserWithIdAndNoPatrolRights( $id ) { + $mock = $this->getMockNonAnonUserWithId( $id ); + + $mock->expects( $this->any() ) + ->method( 'isAllowed' ) + ->will( $this->returnValue( true ) ); + $mock->expects( $this->any() ) + ->method( 'isAllowedAny' ) + ->will( $this->returnValue( true ) ); + + $mock->expects( $this->any() ) + ->method( 'useRCPatrol' ) + ->will( $this->returnValue( false ) ); + $mock->expects( $this->any() ) + ->method( 'useNPPatrol' ) + ->will( $this->returnValue( false ) ); + + return $mock; + } + + private function getMockAnonUser() { + $mock = $this->getMock( User::class ); + $mock->expects( $this->any() ) + ->method( 'isAnon' ) + ->will( $this->returnValue( true ) ); + return $mock; + } + + private function getFakeRow( array $rowValues ) { + $fakeRow = new stdClass(); + foreach ( $rowValues as $valueName => $value ) { + $fakeRow->$valueName = $value; + } + return $fakeRow; + } + + public function testGetWatchedItemsWithRecentChangeInfo() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page' ], + [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp', + 'rc_cur_id', + 'rc_this_oldid', + 'rc_last_oldid', + ], + [ + 'wl_user' => 1, + '(rc_this_oldid=page_latest) OR (rc_type=3)', + ], + $this->isType( 'string' ), + [ + 'LIMIT' => 3, + ], + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ], + 'page' => [ + 'LEFT JOIN', + 'rc_cur_id=page_id', + ], + ] + ) + ->will( $this->returnValue( [ + $this->getFakeRow( [ + 'rc_id' => 1, + 'rc_namespace' => 0, + 'rc_title' => 'Foo1', + 'rc_timestamp' => '20151212010101', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'wl_notificationtimestamp' => '20151212010101', + ] ), + $this->getFakeRow( [ + 'rc_id' => 2, + 'rc_namespace' => 1, + 'rc_title' => 'Foo2', + 'rc_timestamp' => '20151212010102', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'wl_notificationtimestamp' => null, + ] ), + $this->getFakeRow( [ + 'rc_id' => 3, + 'rc_namespace' => 1, + 'rc_title' => 'Foo3', + 'rc_timestamp' => '20151212010103', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'wl_notificationtimestamp' => null, + ] ), + ] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $startFrom = null; + $items = $queryService->getWatchedItemsWithRecentChangeInfo( + $user, [ 'limit' => 2 ], $startFrom + ); + + $this->assertInternalType( 'array', $items ); + $this->assertCount( 2, $items ); + + foreach ( $items as list( $watchedItem, $recentChangeInfo ) ) { + $this->assertInstanceOf( WatchedItem::class, $watchedItem ); + $this->assertInternalType( 'array', $recentChangeInfo ); + } + + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ), + $items[0][0] + ); + $this->assertEquals( + [ + 'rc_id' => 1, + 'rc_namespace' => 0, + 'rc_title' => 'Foo1', + 'rc_timestamp' => '20151212010101', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + ], + $items[0][1] + ); + + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ), + $items[1][0] + ); + $this->assertEquals( + [ + 'rc_id' => 2, + 'rc_namespace' => 1, + 'rc_title' => 'Foo2', + 'rc_timestamp' => '20151212010102', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + ], + $items[1][1] + ); + + $this->assertEquals( [ '20151212010103', 3 ], $startFrom ); + } + + public function testGetWatchedItemsWithRecentChangeInfo_extension() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page', 'extension_dummy_table' ], + [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp', + 'rc_cur_id', + 'rc_this_oldid', + 'rc_last_oldid', + 'extension_dummy_field', + ], + [ + 'wl_user' => 1, + '(rc_this_oldid=page_latest) OR (rc_type=3)', + 'extension_dummy_cond', + ], + $this->isType( 'string' ), + [ + 'extension_dummy_option', + ], + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ], + 'page' => [ + 'LEFT JOIN', + 'rc_cur_id=page_id', + ], + 'extension_dummy_join_cond' => [], + ] + ) + ->will( $this->returnValue( [ + $this->getFakeRow( [ + 'rc_id' => 1, + 'rc_namespace' => 0, + 'rc_title' => 'Foo1', + 'rc_timestamp' => '20151212010101', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'wl_notificationtimestamp' => '20151212010101', + ] ), + $this->getFakeRow( [ + 'rc_id' => 2, + 'rc_namespace' => 1, + 'rc_title' => 'Foo2', + 'rc_timestamp' => '20151212010102', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'wl_notificationtimestamp' => null, + ] ), + ] ) ); + + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $mockExtension = $this->getMockBuilder( WatchedItemQueryServiceExtension::class ) + ->getMock(); + $mockExtension->expects( $this->once() ) + ->method( 'modifyWatchedItemsWithRCInfoQuery' ) + ->with( + $this->identicalTo( $user ), + $this->isType( 'array' ), + $this->isInstanceOf( IDatabase::class ), + $this->isType( 'array' ), + $this->isType( 'array' ), + $this->isType( 'array' ), + $this->isType( 'array' ), + $this->isType( 'array' ) + ) + ->will( $this->returnCallback( function ( + $user, $options, $db, &$tables, &$fields, &$conds, &$dbOptions, &$joinConds + ) { + $tables[] = 'extension_dummy_table'; + $fields[] = 'extension_dummy_field'; + $conds[] = 'extension_dummy_cond'; + $dbOptions[] = 'extension_dummy_option'; + $joinConds['extension_dummy_join_cond'] = []; + } ) ); + $mockExtension->expects( $this->once() ) + ->method( 'modifyWatchedItemsWithRCInfo' ) + ->with( + $this->identicalTo( $user ), + $this->isType( 'array' ), + $this->isInstanceOf( IDatabase::class ), + $this->isType( 'array' ), + $this->anything(), + $this->anything() // Can't test for null here, PHPUnit applies this after the callback + ) + ->will( $this->returnCallback( function ( $user, $options, $db, &$items, $res, &$startFrom ) { + foreach ( $items as $i => &$item ) { + $item[1]['extension_dummy_field'] = $i; + } + unset( $item ); + + $this->assertNull( $startFrom ); + $startFrom = [ '20160203123456', 42 ]; + } ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + TestingAccessWrapper::newFromObject( $queryService )->extensions = [ $mockExtension ]; + + $startFrom = null; + $items = $queryService->getWatchedItemsWithRecentChangeInfo( + $user, [], $startFrom + ); + + $this->assertInternalType( 'array', $items ); + $this->assertCount( 2, $items ); + + foreach ( $items as list( $watchedItem, $recentChangeInfo ) ) { + $this->assertInstanceOf( WatchedItem::class, $watchedItem ); + $this->assertInternalType( 'array', $recentChangeInfo ); + } + + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ), + $items[0][0] + ); + $this->assertEquals( + [ + 'rc_id' => 1, + 'rc_namespace' => 0, + 'rc_title' => 'Foo1', + 'rc_timestamp' => '20151212010101', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'extension_dummy_field' => 0, + ], + $items[0][1] + ); + + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ), + $items[1][0] + ); + $this->assertEquals( + [ + 'rc_id' => 2, + 'rc_namespace' => 1, + 'rc_title' => 'Foo2', + 'rc_timestamp' => '20151212010102', + 'rc_type' => RC_NEW, + 'rc_deleted' => 0, + 'extension_dummy_field' => 1, + ], + $items[1][1] + ); + + $this->assertEquals( [ '20160203123456', 42 ], $startFrom ); + } + + public function getWatchedItemsWithRecentChangeInfoOptionsProvider() { + return [ + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_FLAGS ] ], + null, + [ 'rc_type', 'rc_minor', 'rc_bot' ], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER ] ], + null, + [ 'rc_user_text' ], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER_ID ] ], + null, + [ 'rc_user' ], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ], + null, + [ 'rc_comment' ], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_PATROL_INFO ] ], + null, + [ 'rc_patrolled', 'rc_log_type' ], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_SIZES ] ], + null, + [ 'rc_old_len', 'rc_new_len' ], + [], + [], + ], + [ + [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_LOG_INFO ] ], + null, + [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ], + [], + [], + ], + [ + [ 'namespaceIds' => [ 0, 1 ] ], + null, + [], + [ 'wl_namespace' => [ 0, 1 ] ], + [], + ], + [ + [ 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ] ], + null, + [], + [ 'wl_namespace' => [ 0, 1 ] ], + [], + ], + [ + [ 'rcTypes' => [ RC_EDIT, RC_NEW ] ], + null, + [], + [ 'rc_type' => [ RC_EDIT, RC_NEW ] ], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + null, + [], + [], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ] + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_NEWER ], + null, + [], + [], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ] + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'start' => '20151212010101' ], + null, + [], + [ "rc_timestamp <= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ] + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'end' => '20151212010101' ], + null, + [], + [ "rc_timestamp >= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ] + ], + [ + [ + 'dir' => WatchedItemQueryService::DIR_OLDER, + 'start' => '20151212020101', + 'end' => '20151212010101' + ], + null, + [], + [ "rc_timestamp <= '20151212020101'", "rc_timestamp >= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ] + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'start' => '20151212010101' ], + null, + [], + [ "rc_timestamp >= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ] + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'end' => '20151212010101' ], + null, + [], + [ "rc_timestamp <= '20151212010101'" ], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ] + ], + [ + [ + 'dir' => WatchedItemQueryService::DIR_NEWER, + 'start' => '20151212010101', + 'end' => '20151212020101' + ], + null, + [], + [ "rc_timestamp >= '20151212010101'", "rc_timestamp <= '20151212020101'" ], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ] + ], + [ + [ 'limit' => 10 ], + null, + [], + [], + [ 'LIMIT' => 11 ], + ], + [ + [ 'limit' => "10; DROP TABLE watchlist;\n--" ], + null, + [], + [], + [ 'LIMIT' => 11 ], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_MINOR ] ], + null, + [], + [ 'rc_minor != 0' ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_MINOR ] ], + null, + [], + [ 'rc_minor = 0' ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_BOT ] ], + null, + [], + [ 'rc_bot != 0' ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_BOT ] ], + null, + [], + [ 'rc_bot = 0' ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_ANON ] ], + null, + [], + [ 'rc_user = 0' ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_ANON ] ], + null, + [], + [ 'rc_user != 0' ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_PATROLLED ] ], + null, + [], + [ 'rc_patrolled != 0' ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_PATROLLED ] ], + null, + [], + [ 'rc_patrolled = 0' ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_UNREAD ] ], + null, + [], + [ 'rc_timestamp >= wl_notificationtimestamp' ], + [], + ], + [ + [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_UNREAD ] ], + null, + [], + [ 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp' ], + [], + ], + [ + [ 'onlyByUser' => 'SomeOtherUser' ], + null, + [], + [ 'rc_user_text' => 'SomeOtherUser' ], + [], + ], + [ + [ 'notByUser' => 'SomeOtherUser' ], + null, + [], + [ "rc_user_text != 'SomeOtherUser'" ], + [], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + [ '20151212010101', 123 ], + [], + [ + "(rc_timestamp < '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id <= 123))" + ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_NEWER ], + [ '20151212010101', 123 ], + [], + [ + "(rc_timestamp > '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id >= 123))" + ], + [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ], + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + [ '20151212010101', "123; DROP TABLE watchlist;\n--" ], + [], + [ + "(rc_timestamp < '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id <= 123))" + ], + [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ], + ], + ]; + } + + /** + * @dataProvider getWatchedItemsWithRecentChangeInfoOptionsProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_optionsAndEmptyResult( + array $options, + $startFrom, + array $expectedExtraFields, + array $expectedExtraConds, + array $expectedDbOptions + ) { + $expectedFields = array_merge( + [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp', + + 'rc_cur_id', + 'rc_this_oldid', + 'rc_last_oldid', + ], + $expectedExtraFields + ); + $expectedConds = array_merge( + [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)', ], + $expectedExtraConds + ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page' ], + $expectedFields, + $expectedConds, + $this->isType( 'string' ), + $expectedDbOptions, + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ], + 'page' => [ + 'LEFT JOIN', + 'rc_cur_id=page_id', + ], + ] + ) + ->will( $this->returnValue( [] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom ); + + $this->assertEmpty( $items ); + $this->assertNull( $startFrom ); + } + + public function filterPatrolledOptionProvider() { + return [ + [ WatchedItemQueryService::FILTER_PATROLLED ], + [ WatchedItemQueryService::FILTER_NOT_PATROLLED ], + ]; + } + + /** + * @dataProvider filterPatrolledOptionProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_filterPatrolledAndUserWithNoPatrolRights( + $filtersOption + ) { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page' ], + $this->isType( 'array' ), + [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ], + $this->isType( 'string' ), + $this->isType( 'array' ), + $this->isType( 'array' ) + ) + ->will( $this->returnValue( [] ) ); + + $user = $this->getMockNonAnonUserWithIdAndNoPatrolRights( 1 ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $items = $queryService->getWatchedItemsWithRecentChangeInfo( + $user, + [ 'filters' => [ $filtersOption ] ] + ); + + $this->assertEmpty( $items ); + } + + public function mysqlIndexOptimizationProvider() { + return [ + [ + 'mysql', + [], + [ "rc_timestamp > ''" ], + ], + [ + 'mysql', + [ 'start' => '20151212010101', 'dir' => WatchedItemQueryService::DIR_OLDER ], + [ "rc_timestamp <= '20151212010101'" ], + ], + [ + 'mysql', + [ 'end' => '20151212010101', 'dir' => WatchedItemQueryService::DIR_OLDER ], + [ "rc_timestamp >= '20151212010101'" ], + ], + [ + 'postgres', + [], + [], + ], + ]; + } + + /** + * @dataProvider mysqlIndexOptimizationProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_mysqlIndexOptimization( + $dbType, + array $options, + array $expectedExtraConds + ) { + $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ]; + $conds = array_merge( $commonConds, $expectedExtraConds ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page' ], + $this->isType( 'array' ), + $conds, + $this->isType( 'string' ), + $this->isType( 'array' ), + $this->isType( 'array' ) + ) + ->will( $this->returnValue( [] ) ); + $mockDb->expects( $this->any() ) + ->method( 'getType' ) + ->will( $this->returnValue( $dbType ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options ); + + $this->assertEmpty( $items ); + } + + public function userPermissionRelatedExtraChecksProvider() { + return [ + [ + [], + 'deletedhistory', + [ + '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' . + LogPage::DELETED_ACTION . ')' + ], + ], + [ + [], + 'suppressrevision', + [ + '(rc_type != ' . RC_LOG . ') OR (' . + '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . + ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' + ], + ], + [ + [], + 'viewsuppressed', + [ + '(rc_type != ' . RC_LOG . ') OR (' . + '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . + ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' + ], + ], + [ + [ 'onlyByUser' => 'SomeOtherUser' ], + 'deletedhistory', + [ + 'rc_user_text' => 'SomeOtherUser', + '(rc_deleted & ' . Revision::DELETED_USER . ') != ' . Revision::DELETED_USER, + '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' . + LogPage::DELETED_ACTION . ')' + ], + ], + [ + [ 'onlyByUser' => 'SomeOtherUser' ], + 'suppressrevision', + [ + 'rc_user_text' => 'SomeOtherUser', + '(rc_deleted & ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' . + ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ), + '(rc_type != ' . RC_LOG . ') OR (' . + '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . + ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' + ], + ], + [ + [ 'onlyByUser' => 'SomeOtherUser' ], + 'viewsuppressed', + [ + 'rc_user_text' => 'SomeOtherUser', + '(rc_deleted & ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' . + ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ), + '(rc_type != ' . RC_LOG . ') OR (' . + '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' . + ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')' + ], + ], + ]; + } + + /** + * @dataProvider userPermissionRelatedExtraChecksProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_userPermissionRelatedExtraChecks( + array $options, + $notAllowedAction, + array $expectedExtraConds + ) { + $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ]; + $conds = array_merge( $commonConds, $expectedExtraConds ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page' ], + $this->isType( 'array' ), + $conds, + $this->isType( 'string' ), + $this->isType( 'array' ), + $this->isType( 'array' ) + ) + ->will( $this->returnValue( [] ) ); + + $user = $this->getMockNonAnonUserWithIdAndRestrictedPermissions( 1, $notAllowedAction ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options ); + + $this->assertEmpty( $items ); + } + + public function testGetWatchedItemsWithRecentChangeInfo_allRevisionsOptionAndEmptyResult() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist' ], + [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp', + + 'rc_cur_id', + 'rc_this_oldid', + 'rc_last_oldid', + ], + [ 'wl_user' => 1, ], + $this->isType( 'string' ), + [], + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ], + ] + ) + ->will( $this->returnValue( [] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, [ 'allRevisions' => true ] ); + + $this->assertEmpty( $items ); + } + + public function getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider() { + return [ + [ + [ 'rcTypes' => [ 1337 ] ], + null, + 'Bad value for parameter $options[\'rcTypes\']', + ], + [ + [ 'rcTypes' => [ 'edit' ] ], + null, + 'Bad value for parameter $options[\'rcTypes\']', + ], + [ + [ 'rcTypes' => [ RC_EDIT, 1337 ] ], + null, + 'Bad value for parameter $options[\'rcTypes\']', + ], + [ + [ 'dir' => 'foo' ], + null, + 'Bad value for parameter $options[\'dir\']', + ], + [ + [ 'start' => '20151212010101' ], + null, + 'Bad value for parameter $options[\'dir\']: must be provided', + ], + [ + [ 'end' => '20151212010101' ], + null, + 'Bad value for parameter $options[\'dir\']: must be provided', + ], + [ + [], + [ '20151212010101', 123 ], + 'Bad value for parameter $options[\'dir\']: must be provided', + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + '20151212010101', + 'Bad value for parameter $startFrom: must be a two-element array', + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + [ '20151212010101' ], + 'Bad value for parameter $startFrom: must be a two-element array', + ], + [ + [ 'dir' => WatchedItemQueryService::DIR_OLDER ], + [ '20151212010101', 123, 'foo' ], + 'Bad value for parameter $startFrom: must be a two-element array', + ], + [ + [ 'watchlistOwner' => $this->getMockUnrestrictedNonAnonUserWithId( 2 ) ], + null, + 'Bad value for parameter $options[\'watchlistOwnerToken\']', + ], + [ + [ 'watchlistOwner' => 'Other User', 'watchlistOwnerToken' => 'some-token' ], + null, + 'Bad value for parameter $options[\'watchlistOwner\']', + ], + ]; + } + + /** + * @dataProvider getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_invalidOptions( + array $options, + $startFrom, + $expectedInExceptionMessage + ) { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( $this->anything() ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $this->setExpectedException( InvalidArgumentException::class, $expectedInExceptionMessage ); + $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom ); + } + + public function testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorOptionAndEmptyResult() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist', 'page' ], + [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp', + 'rc_cur_id', + ], + [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ], + $this->isType( 'string' ), + [], + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ], + 'page' => [ + 'LEFT JOIN', + 'rc_cur_id=page_id', + ], + ] + ) + ->will( $this->returnValue( [] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( + $user, + [ 'usedInGenerator' => true ] + ); + + $this->assertEmpty( $items ); + } + + public function testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorAllRevisionsOptions() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + [ 'recentchanges', 'watchlist' ], + [ + 'rc_id', + 'rc_namespace', + 'rc_title', + 'rc_timestamp', + 'rc_type', + 'rc_deleted', + 'wl_notificationtimestamp', + 'rc_this_oldid', + ], + [ 'wl_user' => 1 ], + $this->isType( 'string' ), + [], + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ] + ], + ] + ) + ->will( $this->returnValue( [] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( + $user, + [ 'usedInGenerator' => true, 'allRevisions' => true, ] + ); + + $this->assertEmpty( $items ); + } + + public function testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerOptionAndEmptyResult() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + $this->isType( 'array' ), + $this->isType( 'array' ), + [ + 'wl_user' => 2, + '(rc_this_oldid=page_latest) OR (rc_type=3)', + ], + $this->isType( 'string' ), + $this->isType( 'array' ), + $this->isType( 'array' ) + ) + ->will( $this->returnValue( [] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 ); + $otherUser->expects( $this->once() ) + ->method( 'getOption' ) + ->with( 'watchlisttoken' ) + ->willReturn( '0123456789abcdef' ); + + $items = $queryService->getWatchedItemsWithRecentChangeInfo( + $user, + [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => '0123456789abcdef' ] + ); + + $this->assertEmpty( $items ); + } + + public function invalidWatchlistTokenProvider() { + return [ + [ 'wrongToken' ], + [ '' ], + ]; + } + + /** + * @dataProvider invalidWatchlistTokenProvider + */ + public function testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerAndInvalidToken( $token ) { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->never() ) + ->method( $this->anything() ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 ); + $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 ); + $otherUser->expects( $this->once() ) + ->method( 'getOption' ) + ->with( 'watchlisttoken' ) + ->willReturn( '0123456789abcdef' ); + + $this->setExpectedException( UsageException::class, 'Incorrect watchlist token provided' ); + $queryService->getWatchedItemsWithRecentChangeInfo( + $user, + [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => $token ] + ); + } + + public function testGetWatchedItemsForUser() { + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + [ 'wl_user' => 1 ] + ) + ->will( $this->returnValue( [ + $this->getFakeRow( [ + 'wl_namespace' => 0, + 'wl_title' => 'Foo1', + 'wl_notificationtimestamp' => '20151212010101', + ] ), + $this->getFakeRow( [ + 'wl_namespace' => 1, + 'wl_title' => 'Foo2', + 'wl_notificationtimestamp' => null, + ] ), + ] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + $user = $this->getMockNonAnonUserWithId( 1 ); + + $items = $queryService->getWatchedItemsForUser( $user ); + + $this->assertInternalType( 'array', $items ); + $this->assertCount( 2, $items ); + $this->assertContainsOnlyInstancesOf( WatchedItem::class, $items ); + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ), + $items[0] + ); + $this->assertEquals( + new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ), + $items[1] + ); + } + + public function provideGetWatchedItemsForUserOptions() { + return [ + [ + [ 'namespaceIds' => [ 0, 1 ], ], + [ 'wl_namespace' => [ 0, 1 ], ], + [] + ], + [ + [ 'sort' => WatchedItemQueryService::SORT_ASC, ], + [], + [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] + ], + [ + [ + 'namespaceIds' => [ 0 ], + 'sort' => WatchedItemQueryService::SORT_ASC, + ], + [ 'wl_namespace' => [ 0 ], ], + [ 'ORDER BY' => 'wl_title ASC' ] + ], + [ + [ 'limit' => 10 ], + [], + [ 'LIMIT' => 10 ] + ], + [ + [ + 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ], + 'limit' => "10; DROP TABLE watchlist;\n--", + ], + [ 'wl_namespace' => [ 0, 1 ], ], + [ 'LIMIT' => 10 ] + ], + [ + [ 'filter' => WatchedItemQueryService::FILTER_CHANGED ], + [ 'wl_notificationtimestamp IS NOT NULL' ], + [] + ], + [ + [ 'filter' => WatchedItemQueryService::FILTER_NOT_CHANGED ], + [ 'wl_notificationtimestamp IS NULL' ], + [] + ], + [ + [ 'sort' => WatchedItemQueryService::SORT_DESC, ], + [], + [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] + ], + [ + [ + 'namespaceIds' => [ 0 ], + 'sort' => WatchedItemQueryService::SORT_DESC, + ], + [ 'wl_namespace' => [ 0 ], ], + [ 'ORDER BY' => 'wl_title DESC' ] + ], + ]; + } + + /** + * @dataProvider provideGetWatchedItemsForUserOptions + */ + public function testGetWatchedItemsForUser_optionsAndEmptyResult( + array $options, + array $expectedConds, + array $expectedDbOptions + ) { + $mockDb = $this->getMockDb(); + $user = $this->getMockNonAnonUserWithId( 1 ); + + $expectedConds = array_merge( [ 'wl_user' => 1 ], $expectedConds ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + $expectedConds, + $this->isType( 'string' ), + $expectedDbOptions + ) + ->will( $this->returnValue( [] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + + $items = $queryService->getWatchedItemsForUser( $user, $options ); + $this->assertEmpty( $items ); + } + + public function provideGetWatchedItemsForUser_fromUntilStartFromOptions() { + return [ + [ + [ + 'from' => new TitleValue( 0, 'SomeDbKey' ), + 'sort' => WatchedItemQueryService::SORT_ASC + ], + [ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", ], + [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] + ], + [ + [ + 'from' => new TitleValue( 0, 'SomeDbKey' ), + 'sort' => WatchedItemQueryService::SORT_DESC, + ], + [ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", ], + [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] + ], + [ + [ + 'until' => new TitleValue( 0, 'SomeDbKey' ), + 'sort' => WatchedItemQueryService::SORT_ASC + ], + [ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", ], + [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] + ], + [ + [ + 'until' => new TitleValue( 0, 'SomeDbKey' ), + 'sort' => WatchedItemQueryService::SORT_DESC + ], + [ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", ], + [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] + ], + [ + [ + 'from' => new TitleValue( 0, 'AnotherDbKey' ), + 'until' => new TitleValue( 0, 'SomeOtherDbKey' ), + 'startFrom' => new TitleValue( 0, 'SomeDbKey' ), + 'sort' => WatchedItemQueryService::SORT_ASC + ], + [ + "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'AnotherDbKey'))", + "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeOtherDbKey'))", + "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", + ], + [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ] + ], + [ + [ + 'from' => new TitleValue( 0, 'SomeOtherDbKey' ), + 'until' => new TitleValue( 0, 'AnotherDbKey' ), + 'startFrom' => new TitleValue( 0, 'SomeDbKey' ), + 'sort' => WatchedItemQueryService::SORT_DESC + ], + [ + "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeOtherDbKey'))", + "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'AnotherDbKey'))", + "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", + ], + [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ] + ], + ]; + } + + /** + * @dataProvider provideGetWatchedItemsForUser_fromUntilStartFromOptions + */ + public function testGetWatchedItemsForUser_fromUntilStartFromOptions( + array $options, + array $expectedConds, + array $expectedDbOptions + ) { + $user = $this->getMockNonAnonUserWithId( 1 ); + + $expectedConds = array_merge( [ 'wl_user' => 1 ], $expectedConds ); + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->any() ) + ->method( 'addQuotes' ) + ->will( $this->returnCallback( function( $value ) { + return "'$value'"; + } ) ); + $mockDb->expects( $this->any() ) + ->method( 'makeList' ) + ->with( + $this->isType( 'array' ), + $this->isType( 'int' ) + ) + ->will( $this->returnCallback( function( $a, $conj ) { + $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR '; + return join( $sqlConj, array_map( function( $s ) { + return '(' . $s . ')'; + }, $a + ) ); + } ) ); + $mockDb->expects( $this->once() ) + ->method( 'select' ) + ->with( + 'watchlist', + [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ], + $expectedConds, + $this->isType( 'string' ), + $expectedDbOptions + ) + ->will( $this->returnValue( [] ) ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + + $items = $queryService->getWatchedItemsForUser( $user, $options ); + $this->assertEmpty( $items ); + } + + public function getWatchedItemsForUserInvalidOptionsProvider() { + return [ + [ + [ 'sort' => 'foo' ], + 'Bad value for parameter $options[\'sort\']' + ], + [ + [ 'filter' => 'foo' ], + 'Bad value for parameter $options[\'filter\']' + ], + [ + [ 'from' => new TitleValue( 0, 'SomeDbKey' ), ], + 'Bad value for parameter $options[\'sort\']: must be provided' + ], + [ + [ 'until' => new TitleValue( 0, 'SomeDbKey' ), ], + 'Bad value for parameter $options[\'sort\']: must be provided' + ], + [ + [ 'startFrom' => new TitleValue( 0, 'SomeDbKey' ), ], + 'Bad value for parameter $options[\'sort\']: must be provided' + ], + ]; + } + + /** + * @dataProvider getWatchedItemsForUserInvalidOptionsProvider + */ + public function testGetWatchedItemsForUser_invalidOptionThrowsException( + array $options, + $expectedInExceptionMessage + ) { + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $this->getMockDb() ) ); + + $this->setExpectedException( InvalidArgumentException::class, $expectedInExceptionMessage ); + $queryService->getWatchedItemsForUser( $this->getMockNonAnonUserWithId( 1 ), $options ); + } + + public function testGetWatchedItemsForUser_userNotAllowedToViewWatchlist() { + $mockDb = $this->getMockDb(); + + $mockDb->expects( $this->never() ) + ->method( $this->anything() ); + + $queryService = new WatchedItemQueryService( $this->getMockLoadBalancer( $mockDb ) ); + + $items = $queryService->getWatchedItemsForUser( $this->getMockAnonUser() ); + $this->assertEmpty( $items ); + } + +} diff --git a/tests/phpunit/includes/WatchedItemStoreIntegrationTest.php b/tests/phpunit/includes/WatchedItemStoreIntegrationTest.php index f34af6113d..61b62aa66b 100644 --- a/tests/phpunit/includes/WatchedItemStoreIntegrationTest.php +++ b/tests/phpunit/includes/WatchedItemStoreIntegrationTest.php @@ -106,7 +106,7 @@ class WatchedItemStoreIntegrationTest extends MediaWikiTestCase { ); } - public function testUpdateAndResetNotificationTimestamp() { + public function testUpdateResetAndSetNotificationTimestamp() { $user = $this->getUser(); $otherUser = ( new TestUser( 'WatchedItemStoreIntegrationTestUser_otherUser' ) )->getUser(); $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' ); @@ -172,6 +172,24 @@ class WatchedItemStoreIntegrationTest extends MediaWikiTestCase { [ [ $title, '20150202020202' ] ], $initialVisitingWatchers + 1 ) ); + + // setNotificationTimestampsForUser specifying a title + $this->assertTrue( + $store->setNotificationTimestampsForUser( $user, '20200202020202', [ $title ] ) + ); + $this->assertEquals( + '20200202020202', + $store->getWatchedItem( $user, $title )->getNotificationTimestamp() + ); + + // setNotificationTimestampsForUser not specifying a title + $this->assertTrue( + $store->setNotificationTimestampsForUser( $user, '20210202020202' ) + ); + $this->assertEquals( + '20210202020202', + $store->getWatchedItem( $user, $title )->getNotificationTimestamp() + ); } public function testDuplicateAllAssociatedEntries() { diff --git a/tests/phpunit/includes/WatchedItemStoreUnitTest.php b/tests/phpunit/includes/WatchedItemStoreUnitTest.php index 6c4a6f09c6..ba4705970c 100644 --- a/tests/phpunit/includes/WatchedItemStoreUnitTest.php +++ b/tests/phpunit/includes/WatchedItemStoreUnitTest.php @@ -1,5 +1,6 @@ getMock(); if ( $expectedConnectionType !== null ) { $mock->expects( $this->any() ) - ->method( 'getConnection' ) + ->method( 'getConnectionRef' ) ->with( $expectedConnectionType ) ->will( $this->returnValue( $mockDb ) ); } else { $mock->expects( $this->any() ) - ->method( 'getConnection' ) + ->method( 'getConnectionRef' ) ->will( $this->returnValue( $mockDb ) ); } $mock->expects( $this->any() ) @@ -2366,13 +2367,88 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase { ScopedCallback::consume( $scopedOverrideRevision ); } + public function testSetNotificationTimestampsForUser_anonUser() { + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $this->getMockDb() ), + $this->getMockCache() + ); + $this->assertFalse( $store->setNotificationTimestampsForUser( $this->getAnonUser(), '' ) ); + } + + public function testSetNotificationTimestampsForUser_allRows() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $timestamp = '20100101010101'; + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'update' ) + ->with( + 'watchlist', + [ 'wl_notificationtimestamp' => 'TS' . $timestamp . 'TS' ], + [ 'wl_user' => 1 ] + ) + ->will( $this->returnValue( true ) ); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'timestamp' ) + ->will( $this->returnCallback( function( $value ) { + return 'TS' . $value . 'TS'; + } ) ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $this->getMockCache() + ); + + $this->assertTrue( + $store->setNotificationTimestampsForUser( $user, $timestamp ) + ); + } + + public function testSetNotificationTimestampsForUser_specificTargets() { + $user = $this->getMockNonAnonUserWithId( 1 ); + $timestamp = '20100101010101'; + $targets = [ new TitleValue( 0, 'Foo' ), new TitleValue( 0, 'Bar' ) ]; + + $mockDb = $this->getMockDb(); + $mockDb->expects( $this->once() ) + ->method( 'update' ) + ->with( + 'watchlist', + [ 'wl_notificationtimestamp' => 'TS' . $timestamp . 'TS' ], + [ 'wl_user' => 1, 0 => 'makeWhereFrom2d return value' ] + ) + ->will( $this->returnValue( true ) ); + $mockDb->expects( $this->exactly( 1 ) ) + ->method( 'timestamp' ) + ->will( $this->returnCallback( function( $value ) { + return 'TS' . $value . 'TS'; + } ) ); + $mockDb->expects( $this->once() ) + ->method( 'makeWhereFrom2d' ) + ->with( + [ [ 'Foo' => 1, 'Bar' => 1 ] ], + $this->isType( 'string' ), + $this->isType( 'string' ) + ) + ->will( $this->returnValue( 'makeWhereFrom2d return value' ) ); + + $store = $this->newWatchedItemStore( + $this->getMockLoadBalancer( $mockDb ), + $this->getMockCache() + ); + + $this->assertTrue( + $store->setNotificationTimestampsForUser( $user, $timestamp, $targets ) + ); + } + public function testUpdateNotificationTimestamp_watchersExist() { $mockDb = $this->getMockDb(); $mockDb->expects( $this->once() ) - ->method( 'select' ) + ->method( 'selectFieldValues' ) ->with( - [ 'watchlist' ], - [ 'wl_user' ], + 'watchlist', + 'wl_user', [ 'wl_user != 1', 'wl_namespace' => 0, @@ -2380,18 +2456,7 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase { 'wl_notificationtimestamp IS NULL' ] ) - ->will( - $this->returnValue( [ - $this->getFakeRow( [ 'wl_user' => '2' ] ), - $this->getFakeRow( [ 'wl_user' => '3' ] ) - ] ) - ); - $mockDb->expects( $this->once() ) - ->method( 'onTransactionIdle' ) - ->with( $this->isType( 'callable' ) ) - ->will( $this->returnCallback( function( $callable ) { - $callable(); - } ) ); + ->will( $this->returnValue( [ '2', '3' ] ) ); $mockDb->expects( $this->once() ) ->method( 'update' ) ->with( @@ -2427,10 +2492,10 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase { public function testUpdateNotificationTimestamp_noWatchers() { $mockDb = $this->getMockDb(); $mockDb->expects( $this->once() ) - ->method( 'select' ) + ->method( 'selectFieldValues' ) ->with( - [ 'watchlist' ], - [ 'wl_user' ], + 'watchlist', + 'wl_user', [ 'wl_user != 1', 'wl_namespace' => 0, @@ -2441,8 +2506,6 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase { ->will( $this->returnValue( [] ) ); - $mockDb->expects( $this->never() ) - ->method( 'onTransactionIdle' ); $mockDb->expects( $this->never() ) ->method( 'update' ); @@ -2476,19 +2539,10 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase { $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] ) ) ); $mockDb->expects( $this->once() ) - ->method( 'select' ) + ->method( 'selectFieldValues' ) ->will( - $this->returnValue( [ - $this->getFakeRow( [ 'wl_user' => '2' ] ), - $this->getFakeRow( [ 'wl_user' => '3' ] ) - ] ) + $this->returnValue( [ '2', '3' ] ) ); - $mockDb->expects( $this->once() ) - ->method( 'onTransactionIdle' ) - ->with( $this->isType( 'callable' ) ) - ->will( $this->returnCallback( function( $callable ) { - $callable(); - } ) ); $mockDb->expects( $this->once() ) ->method( 'update' ); diff --git a/tests/phpunit/includes/WatchedItemUnitTest.php b/tests/phpunit/includes/WatchedItemUnitTest.php index 0182eb7fc4..7e1ff3d7a9 100644 --- a/tests/phpunit/includes/WatchedItemUnitTest.php +++ b/tests/phpunit/includes/WatchedItemUnitTest.php @@ -78,35 +78,6 @@ class WatchedItemUnitTest extends MediaWikiTestCase { $this->assertEquals( $timestamp, $item->getNotificationTimestamp() ); } - /** - * @dataProvider provideUserTitleTimestamp - */ - public function testResetNotificationTimestamp( $user, $linkTarget, $timestamp ) { - $force = 'XXX'; - $oldid = 999; - - $store = $this->getMockWatchedItemStore(); - $store->expects( $this->once() ) - ->method( 'resetNotificationTimestamp' ) - ->with( $user, $this->isInstanceOf( Title::class ), $force, $oldid ) - ->will( $this->returnCallback( - function ( $user, Title $title, $force, $oldid ) use ( $linkTarget ) { - /** @var LinkTarget $linkTarget */ - $this->assertInstanceOf( 'Title', $title ); - $this->assertSame( $linkTarget->getDBkey(), $title->getDBkey() ); - $this->assertSame( $linkTarget->getFragment(), $title->getFragment() ); - $this->assertSame( $linkTarget->getNamespace(), $title->getNamespace() ); - $this->assertSame( $linkTarget->getText(), $title->getText() ); - - return true; - } - ) ); - $this->setService( 'WatchedItemStore', $store ); - - $item = new WatchedItem( $user, $linkTarget, $timestamp ); - $item->resetNotificationTimestamp( $force, $oldid ); - } - public function testAddWatch() { $title = Title::newFromText( 'SomeTitle' ); $timestamp = null; @@ -176,38 +147,4 @@ class WatchedItemUnitTest extends MediaWikiTestCase { WatchedItem::duplicateEntries( $oldTitle, $newTitle ); } - public function testBatchAddWatch() { - $itemOne = new WatchedItem( $this->getMockUser( 1 ), new TitleValue( 0, 'Title1' ), null ); - $itemTwo = new WatchedItem( - $this->getMockUser( 3 ), - Title::newFromText( 'Title2' ), - '20150101010101' - ); - - $store = $this->getMockWatchedItemStore(); - $store->expects( $this->exactly( 2 ) ) - ->method( 'addWatchBatchForUser' ); - $store->expects( $this->at( 0 ) ) - ->method( 'addWatchBatchForUser' ) - ->with( - $itemOne->getUser(), - [ - $itemOne->getTitle()->getSubjectPage(), - $itemOne->getTitle()->getTalkPage(), - ] - ); - $store->expects( $this->at( 1 ) ) - ->method( 'addWatchBatchForUser' ) - ->with( - $itemTwo->getUser(), - [ - $itemTwo->getTitle()->getSubjectPage(), - $itemTwo->getTitle()->getTalkPage(), - ] - ); - $this->setService( 'WatchedItemStore', $store ); - - WatchedItem::batchAddWatch( [ $itemOne, $itemTwo ] ); - } - } diff --git a/tests/phpunit/includes/WebRequestTest.php b/tests/phpunit/includes/WebRequestTest.php index c946689353..041e7e3cd6 100644 --- a/tests/phpunit/includes/WebRequestTest.php +++ b/tests/phpunit/includes/WebRequestTest.php @@ -10,12 +10,10 @@ class WebRequestTest extends MediaWikiTestCase { parent::setUp(); $this->oldServer = $_SERVER; - IP::clearCaches(); } protected function tearDown() { $_SERVER = $this->oldServer; - IP::clearCaches(); parent::tearDown(); } @@ -23,8 +21,11 @@ class WebRequestTest extends MediaWikiTestCase { /** * @dataProvider provideDetectServer * @covers WebRequest::detectServer + * @covers WebRequest::detectProtocol */ public function testDetectServer( $expected, $input, $description ) { + $this->setMwGlobals( 'wgAssumeProxiesUseDefaultProtocolPorts', true ); + $_SERVER = $input; $result = WebRequest::detectServer(); $this->assertEquals( $expected, $result, $description ); @@ -63,6 +64,24 @@ class WebRequestTest extends MediaWikiTestCase { ], 'Secure off' ], + [ + 'https://x', + [ + 'HTTP_HOST' => 'x', + 'HTTP_X_FORWARDED_PROTO' => 'https', + ], + 'Forwarded HTTPS' + ], + [ + 'https://x', + [ + 'HTTP_HOST' => 'x', + 'HTTPS' => 'off', + 'SERVER_PORT' => '81', + 'HTTP_X_FORWARDED_PROTO' => 'https', + ], + 'Forwarded HTTPS' + ], [ 'http://y', [ @@ -104,6 +123,241 @@ class WebRequestTest extends MediaWikiTestCase { ]; } + protected function mockWebRequest( $data = [] ) { + // Cannot use PHPUnit getMockBuilder() as it does not support + // overriding protected properties afterwards + $reflection = new ReflectionClass( 'WebRequest' ); + $req = $reflection->newInstanceWithoutConstructor(); + + $prop = $reflection->getProperty( 'data' ); + $prop->setAccessible( true ); + $prop->setValue( $req, $data ); + + $prop = $reflection->getProperty( 'requestTime' ); + $prop->setAccessible( true ); + $prop->setValue( $req, microtime( true ) ); + + return $req; + } + + /** + * @covers WebRequest::getElapsedTime + */ + public function testGetElapsedTime() { + $req = $this->mockWebRequest(); + $this->assertGreaterThanOrEqual( 0.0, $req->getElapsedTime() ); + $this->assertEquals( 0.0, $req->getElapsedTime(), '', /*delta*/ 0.2 ); + } + + /** + * @covers WebRequest::getVal + * @covers WebRequest::getGPCVal + * @covers WebRequest::normalizeUnicode + */ + public function testGetValNormal() { + // Assert that WebRequest normalises GPC data using UtfNormal\Validator + $input = "a \x00 null"; + $normal = "a \xef\xbf\xbd null"; + $req = $this->mockWebRequest( [ 'x' => $input, 'y' => [ $input, $input ] ] ); + $this->assertSame( $normal, $req->getVal( 'x' ) ); + $this->assertNotSame( $input, $req->getVal( 'x' ) ); + $this->assertSame( [ $normal, $normal ], $req->getArray( 'y' ) ); + } + + /** + * @covers WebRequest::getVal + * @covers WebRequest::getGPCVal + */ + public function testGetVal() { + $req = $this->mockWebRequest( [ 'x' => 'Value', 'y' => [ 'a' ], 'crlf' => "A\r\nb" ] ); + $this->assertSame( 'Value', $req->getVal( 'x' ), 'Simple value' ); + $this->assertSame( null, $req->getVal( 'z' ), 'Not found' ); + $this->assertSame( null, $req->getVal( 'y' ), 'Array is ignored' ); + $this->assertSame( "A\r\nb", $req->getVal( 'crlf' ), 'CRLF' ); + } + + /** + * @covers WebRequest::getRawVal + */ + public function testGetRawVal() { + $req = $this->mockWebRequest( [ + 'x' => 'Value', + 'y' => [ 'a' ], + 'crlf' => "A\r\nb" + ] ); + $this->assertSame( 'Value', $req->getRawVal( 'x' ) ); + $this->assertSame( null, $req->getRawVal( 'z' ), 'Not found' ); + $this->assertSame( null, $req->getRawVal( 'y' ), 'Array is ignored' ); + $this->assertSame( "A\r\nb", $req->getRawVal( 'crlf' ), 'CRLF' ); + } + + /** + * @covers WebRequest::getArray + */ + public function testGetArray() { + $req = $this->mockWebRequest( [ 'x' => 'Value', 'y' => [ 'a', 'b' ] ] ); + $this->assertSame( [ 'Value' ], $req->getArray( 'x' ), 'Value becomes array' ); + $this->assertSame( null, $req->getArray( 'z' ), 'Not found' ); + $this->assertSame( [ 'a', 'b' ], $req->getArray( 'y' ) ); + } + + /** + * @covers WebRequest::getIntArray + */ + public function testGetIntArray() { + $req = $this->mockWebRequest( [ 'x' => [ 'Value' ], 'y' => [ '0', '4.2', '-2' ] ] ); + $this->assertSame( [ 0 ], $req->getIntArray( 'x' ), 'Text becomes 0' ); + $this->assertSame( null, $req->getIntArray( 'z' ), 'Not found' ); + $this->assertSame( [ 0, 4, -2 ], $req->getIntArray( 'y' ) ); + } + + /** + * @covers WebRequest::getInt + */ + public function testGetInt() { + $req = $this->mockWebRequest( [ + 'x' => 'Value', + 'y' => [ 'a' ], + 'zero' => '0', + 'answer' => '4.2', + 'neg' => '-2', + ] ); + $this->assertSame( 0, $req->getInt( 'x' ), 'Text' ); + $this->assertSame( 0, $req->getInt( 'y' ), 'Array' ); + $this->assertSame( 0, $req->getInt( 'z' ), 'Not found' ); + $this->assertSame( 0, $req->getInt( 'zero' ) ); + $this->assertSame( 4, $req->getInt( 'answer' ) ); + $this->assertSame( -2, $req->getInt( 'neg' ) ); + } + + /** + * @covers WebRequest::getIntOrNull + */ + public function testGetIntOrNull() { + $req = $this->mockWebRequest( [ + 'x' => 'Value', + 'y' => [ 'a' ], + 'zero' => '0', + 'answer' => '4.2', + 'neg' => '-2', + ] ); + $this->assertSame( null, $req->getIntOrNull( 'x' ), 'Text' ); + $this->assertSame( null, $req->getIntOrNull( 'y' ), 'Array' ); + $this->assertSame( null, $req->getIntOrNull( 'z' ), 'Not found' ); + $this->assertSame( 0, $req->getIntOrNull( 'zero' ) ); + $this->assertSame( 4, $req->getIntOrNull( 'answer' ) ); + $this->assertSame( -2, $req->getIntOrNull( 'neg' ) ); + } + + /** + * @covers WebRequest::getFloat + */ + public function testGetFloat() { + $req = $this->mockWebRequest( [ + 'x' => 'Value', + 'y' => [ 'a' ], + 'zero' => '0', + 'answer' => '4.2', + 'neg' => '-2', + ] ); + $this->assertSame( 0.0, $req->getFloat( 'x' ), 'Text' ); + $this->assertSame( 0.0, $req->getFloat( 'y' ), 'Array' ); + $this->assertSame( 0.0, $req->getFloat( 'z' ), 'Not found' ); + $this->assertSame( 0.0, $req->getFloat( 'zero' ) ); + $this->assertSame( 4.2, $req->getFloat( 'answer' ) ); + $this->assertSame( -2.0, $req->getFloat( 'neg' ) ); + } + + /** + * @covers WebRequest::getBool + */ + public function testGetBool() { + $req = $this->mockWebRequest( [ + 'x' => 'Value', + 'y' => [ 'a' ], + 'zero' => '0', + 'f' => 'false', + 't' => 'true', + ] ); + $this->assertSame( true, $req->getBool( 'x' ), 'Text' ); + $this->assertSame( false, $req->getBool( 'y' ), 'Array' ); + $this->assertSame( false, $req->getBool( 'z' ), 'Not found' ); + $this->assertSame( false, $req->getBool( 'zero' ) ); + $this->assertSame( true, $req->getBool( 'f' ) ); + $this->assertSame( true, $req->getBool( 't' ) ); + } + + public static function provideFuzzyBool() { + return [ + [ 'Text', true ], + [ '', false, '(empty string)' ], + [ '0', false ], + [ '1', true ], + [ 'false', false ], + [ 'true', true ], + [ 'False', false ], + [ 'True', true ], + [ 'FALSE', false ], + [ 'TRUE', true ], + ]; + } + + /** + * @dataProvider provideFuzzyBool + * @covers WebRequest::getFuzzyBool + */ + public function testGetFuzzyBool( $value, $expected, $message = null ) { + $req = $this->mockWebRequest( [ 'x' => $value ] ); + $this->assertSame( $expected, $req->getFuzzyBool( 'x' ), $message ?: "Value: '$value'" ); + } + + /** + * @covers WebRequest::getFuzzyBool + */ + public function testGetFuzzyBoolDefault() { + $req = $this->mockWebRequest(); + $this->assertSame( false, $req->getFuzzyBool( 'z' ), 'Not found' ); + } + + /** + * @covers WebRequest::getCheck + */ + public function testGetCheck() { + $req = $this->mockWebRequest( [ 'x' => 'Value', 'zero' => '0' ] ); + $this->assertSame( false, $req->getCheck( 'z' ), 'Not found' ); + $this->assertSame( true, $req->getCheck( 'x' ), 'Text' ); + $this->assertSame( true, $req->getCheck( 'zero' ) ); + } + + /** + * @covers WebRequest::getText + */ + public function testGetText() { + // Avoid FauxRequest (overrides getText) + $req = $this->mockWebRequest( [ 'crlf' => "Va\r\nlue" ] ); + $this->assertSame( "Va\nlue", $req->getText( 'crlf' ), 'CR stripped' ); + } + + /** + * @covers WebRequest::getValues + */ + public function testGetValues() { + $values = [ 'x' => 'Value', 'y' => '' ]; + // Avoid FauxRequest (overrides getValues) + $req = $this->mockWebRequest( $values ); + $this->assertSame( $values, $req->getValues() ); + $this->assertSame( [ 'x' => 'Value' ], $req->getValues( 'x' ), 'Specific keys' ); + } + + /** + * @covers WebRequest::getValueNames + */ + public function testGetValueNames() { + $req = $this->mockWebRequest( [ 'x' => 'Value', 'y' => '' ] ); + $this->assertSame( [ 'x', 'y' ], $req->getValueNames() ); + $this->assertSame( [ 'x' ], $req->getValueNames( [ 'y' ] ), 'Exclude keys' ); + } + /** * @dataProvider provideGetIP * @covers WebRequest::getIP @@ -111,7 +365,6 @@ class WebRequestTest extends MediaWikiTestCase { public function testGetIP( $expected, $input, $squid, $xffList, $private, $description ) { $_SERVER = $input; $this->setMwGlobals( [ - 'wgSquidServersNoPurge' => $squid, 'wgUsePrivateIPs' => $private, 'wgHooks' => [ 'IsTrustedProxy' => [ @@ -123,6 +376,8 @@ class WebRequestTest extends MediaWikiTestCase { ] ] ); + $this->setService( 'ProxyLookup', new ProxyLookup( [], $squid ) ); + $request = new WebRequest(); $result = $request->getIP(); $this->assertEquals( $expected, $result, $description ); @@ -308,6 +563,7 @@ class WebRequestTest extends MediaWikiTestCase { 'wgUsePrivateIPs' => false, 'wgHooks' => [], ] ); + $this->setService( 'ProxyLookup', new ProxyLookup( [], [] ) ); $request = new WebRequest(); # Next call throw an exception about lacking an IP @@ -343,6 +599,7 @@ class WebRequestTest extends MediaWikiTestCase { [ 'en-gb' => 1, 'en-us' => '1' ], 'Two equally prefered English variants' ], + [ '_', [], 'Invalid input' ], ]; } diff --git a/tests/phpunit/includes/WikiMapTest.php b/tests/phpunit/includes/WikiMapTest.php index 4e22e3c7d6..12878b37ed 100644 --- a/tests/phpunit/includes/WikiMapTest.php +++ b/tests/phpunit/includes/WikiMapTest.php @@ -15,6 +15,7 @@ class WikiMapTest extends MediaWikiLangTestCase { 'wgServer' => [ 'enwiki' => 'http://en.example.org', 'ruwiki' => '//ru.example.org', + 'nopathwiki' => '//nopath.example.org', ], 'wgArticlePath' => [ 'enwiki' => '/w/$1', @@ -46,6 +47,8 @@ class WikiMapTest extends MediaWikiLangTestCase { 'nlwiki (sites)' => [ $nlwiki, 'nlwiki', false ], 'enwiktionary (sites)' => [ $enwiktionary, 'enwiktionary', false ], 'non MediaWiki site' => [ null, 'spam', false ], + 'boguswiki' => [ null, 'boguswiki' ], + 'nopathwiki' => [ null, 'nopathwiki' ], ]; } diff --git a/tests/phpunit/includes/XmlSelectTest.php b/tests/phpunit/includes/XmlSelectTest.php index 0d10c1a83c..52e20bdb99 100644 --- a/tests/phpunit/includes/XmlSelectTest.php +++ b/tests/phpunit/includes/XmlSelectTest.php @@ -12,9 +12,6 @@ class XmlSelectTest extends MediaWikiTestCase { protected function setUp() { parent::setUp(); - $this->setMwGlobals( [ - 'wgWellFormedXml' => true, - ] ); $this->select = new XmlSelect(); } @@ -53,7 +50,7 @@ class XmlSelectTest extends MediaWikiTestCase { /** * Values are set following a 3-bit Gray code where two successive * values differ by only one value. - * See http://en.wikipedia.org/wiki/Gray_code + * See https://en.wikipedia.org/wiki/Gray_code */ # $name $id $default [ false, false, false, '' ], diff --git a/tests/phpunit/includes/XmlTest.php b/tests/phpunit/includes/XmlTest.php index 00d429e458..18ff1f4b11 100644 --- a/tests/phpunit/includes/XmlTest.php +++ b/tests/phpunit/includes/XmlTest.php @@ -30,7 +30,6 @@ class XmlTest extends MediaWikiTestCase { $this->setMwGlobals( [ 'wgLang' => $langObj, - 'wgWellFormedXml' => true, 'wgUseMediaWikiUIEverywhere' => false, ] ); } @@ -306,17 +305,6 @@ class XmlTest extends MediaWikiTestCase { ); } - /** - * @covers Xml::escapeJsString - */ - public function testEscapeJsStringSpecialChars() { - $this->assertEquals( - '\\\\\r\n', - Xml::escapeJsString( "\\\r\n" ), - 'escapeJsString() with special characters' - ); - } - /** * @covers Xml::encodeJsVar */ diff --git a/tests/phpunit/includes/api/ApiBaseTest.php b/tests/phpunit/includes/api/ApiBaseTest.php index 5d1ead0c2f..8b75d56281 100644 --- a/tests/phpunit/includes/api/ApiBaseTest.php +++ b/tests/phpunit/includes/api/ApiBaseTest.php @@ -43,4 +43,87 @@ class ApiBaseTest extends ApiTestCase { ); } + /** + * @dataProvider provideGetParameterFromSettings + * @param string|null $input + * @param array $paramSettings + * @param mixed $expected + * @param string[] $warnings + */ + public function testGetParameterFromSettings( $input, $paramSettings, $expected, $warnings ) { + $mock = new MockApi(); + $wrapper = TestingAccessWrapper::newFromObject( $mock ); + + $context = new DerivativeContext( $mock ); + $context->setRequest( new FauxRequest( $input !== null ? [ 'foo' => $input ] : [] ) ); + $wrapper->mMainModule = new ApiMain( $context ); + + if ( $expected instanceof UsageException ) { + try { + $wrapper->getParameterFromSettings( 'foo', $paramSettings, true ); + } catch ( UsageException $ex ) { + $this->assertEquals( $expected, $ex ); + } + } else { + $result = $wrapper->getParameterFromSettings( 'foo', $paramSettings, true ); + $this->assertSame( $expected, $result ); + $this->assertSame( $warnings, $mock->warnings ); + } + } + + public static function provideGetParameterFromSettings() { + $warnings = [ + 'The value passed for \'foo\' contains invalid or non-normalized data. Textual data should ' . + 'be valid, NFC-normalized Unicode without C0 control characters other than ' . + 'HT (\\t), LF (\\n), and CR (\\r).' + ]; + + $c0 = ''; + $enc = ''; + for ( $i = 0; $i < 32; $i++ ) { + $c0 .= chr( $i ); + $enc .= ( $i === 9 || $i === 10 || $i === 13 ) + ? chr( $i ) + : '�'; + } + + return [ + 'Basic param' => [ 'bar', null, 'bar', [] ], + 'Basic param, C0 controls' => [ $c0, null, $enc, $warnings ], + 'String param' => [ 'bar', '', 'bar', [] ], + 'String param, defaulted' => [ null, '', '', [] ], + 'String param, empty' => [ '', 'default', '', [] ], + 'String param, required, empty' => [ + '', + [ ApiBase::PARAM_DFLT => 'default', ApiBase::PARAM_REQUIRED => true ], + new UsageException( 'The foo parameter must be set', 'nofoo' ), + [] + ], + 'Multi-valued parameter' => [ + 'a|b|c', + [ ApiBase::PARAM_ISMULTI => true ], + [ 'a', 'b', 'c' ], + [] + ], + 'Multi-valued parameter, alternative separator' => [ + "\x1fa|b\x1fc|d", + [ ApiBase::PARAM_ISMULTI => true ], + [ 'a|b', 'c|d' ], + [] + ], + 'Multi-valued parameter, other C0 controls' => [ + $c0, + [ ApiBase::PARAM_ISMULTI => true ], + [ $enc ], + $warnings + ], + 'Multi-valued parameter, other C0 controls (2)' => [ + "\x1f" . $c0, + [ ApiBase::PARAM_ISMULTI => true ], + [ substr( $enc, 0, -3 ), '' ], + $warnings + ], + ]; + } + } diff --git a/tests/phpunit/includes/api/ApiContinuationManagerTest.php b/tests/phpunit/includes/api/ApiContinuationManagerTest.php index 6da16a0931..3ad16d1322 100644 --- a/tests/phpunit/includes/api/ApiContinuationManagerTest.php +++ b/tests/phpunit/includes/api/ApiContinuationManagerTest.php @@ -195,7 +195,6 @@ class ApiContinuationManagerTest extends MediaWikiTestCase { 'Expected exception' ); } - } } diff --git a/tests/phpunit/includes/api/ApiCreateAccountTest.php b/tests/phpunit/includes/api/ApiCreateAccountTest.php deleted file mode 100644 index 9a83e61d57..0000000000 --- a/tests/phpunit/includes/api/ApiCreateAccountTest.php +++ /dev/null @@ -1,160 +0,0 @@ -setMwGlobals( [ 'wgEnableEmail' => true ] ); - } - - /** - * Test the account creation API with a valid request. Also - * make sure the new account can log in and is valid. - * - * This test does multiple API requests so it might end up being - * a bit slow. Raise the default timeout. - * @group medium - */ - public function testValid() { - global $wgServer; - - if ( !isset( $wgServer ) ) { - $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' ); - } - - $password = PasswordFactory::generateRandomPasswordString(); - - $ret = $this->doApiRequest( [ - 'action' => 'createaccount', - 'name' => 'Apitestnew', - 'password' => $password, - 'email' => 'test@domain.test', - 'realname' => 'Test Name' - ] ); - - $result = $ret[0]; - $this->assertNotInternalType( 'bool', $result ); - $this->assertNotInternalType( 'null', $result['createaccount'] ); - - // Should first ask for token. - $a = $result['createaccount']; - $this->assertEquals( 'NeedToken', $a['result'] ); - $token = $a['token']; - - // Finally create the account - $ret = $this->doApiRequest( - [ - 'action' => 'createaccount', - 'name' => 'Apitestnew', - 'password' => $password, - 'token' => $token, - 'email' => 'test@domain.test', - 'realname' => 'Test Name' - ], - $ret[2] - ); - - $result = $ret[0]; - $this->assertNotInternalType( 'bool', $result ); - $this->assertEquals( 'Success', $result['createaccount']['result'] ); - - // Try logging in with the new user. - $ret = $this->doApiRequest( [ - 'action' => 'login', - 'lgname' => 'Apitestnew', - 'lgpassword' => $password, - ] ); - - $result = $ret[0]; - $this->assertNotInternalType( 'bool', $result ); - $this->assertNotInternalType( 'null', $result['login'] ); - - $a = $result['login']['result']; - $this->assertEquals( 'NeedToken', $a ); - $token = $result['login']['token']; - - $ret = $this->doApiRequest( - [ - 'action' => 'login', - 'lgtoken' => $token, - 'lgname' => 'Apitestnew', - 'lgpassword' => $password, - ], - $ret[2] - ); - - $result = $ret[0]; - - $this->assertNotInternalType( 'bool', $result ); - $a = $result['login']['result']; - - $this->assertEquals( 'Success', $a ); - - // log out to destroy the session - $ret = $this->doApiRequest( - [ - 'action' => 'logout', - ], - $ret[2] - ); - $this->assertEquals( [], $ret[0] ); - } - - /** - * Make sure requests with no names are invalid. - * @expectedException UsageException - */ - public function testNoName() { - $this->doApiRequest( [ - 'action' => 'createaccount', - 'token' => LoginForm::getCreateaccountToken()->toString(), - 'password' => 'password', - ] ); - } - - /** - * Make sure requests with no password are invalid. - * @expectedException UsageException - */ - public function testNoPassword() { - $this->doApiRequest( [ - 'action' => 'createaccount', - 'name' => 'testName', - 'token' => LoginForm::getCreateaccountToken()->toString(), - ] ); - } - - /** - * Make sure requests with existing users are invalid. - * @expectedException UsageException - */ - public function testExistingUser() { - $this->doApiRequest( [ - 'action' => 'createaccount', - 'name' => 'Apitestsysop', - 'token' => LoginForm::getCreateaccountToken()->toString(), - 'password' => 'password', - 'email' => 'test@domain.test', - ] ); - } - - /** - * Make sure requests with invalid emails are invalid. - * @expectedException UsageException - */ - public function testInvalidEmail() { - $this->doApiRequest( [ - 'action' => 'createaccount', - 'name' => 'Test User', - 'token' => LoginForm::getCreateaccountToken()->toString(), - 'password' => 'password', - 'email' => 'invalid', - ] ); - } -} diff --git a/tests/phpunit/includes/api/ApiEditPageTest.php b/tests/phpunit/includes/api/ApiEditPageTest.php index 7a8e208b0c..02d0a0dc57 100644 --- a/tests/phpunit/includes/api/ApiEditPageTest.php +++ b/tests/phpunit/includes/api/ApiEditPageTest.php @@ -513,4 +513,57 @@ class ApiEditPageTest extends ApiTestCase { $this->assertEquals( "testing-nontext", $page->getContentModel() ); $this->assertEquals( $data, $page->getContent()->serialize() ); } + + /** + * This test verifies that after changing the content model + * of a page, undoing that edit via the API will also + * undo the content model change. + */ + public function testUndoAfterContentModelChange() { + $name = 'Help:' . __FUNCTION__; + $uploader = self::$users['uploader']->getUser(); + $sysop = self::$users['sysop']->getUser(); + $apiResult = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'some text', + ], null, $sysop )[0]; + + // Check success + $this->assertArrayHasKey( 'edit', $apiResult ); + $this->assertArrayHasKey( 'result', $apiResult['edit'] ); + $this->assertEquals( 'Success', $apiResult['edit']['result'] ); + $this->assertArrayHasKey( 'contentmodel', $apiResult['edit'] ); + // Content model is wikitext + $this->assertEquals( 'wikitext', $apiResult['edit']['contentmodel'] ); + + // Convert the page to JSON + $apiResult = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => '{}', + 'contentmodel' => 'json', + ], null, $uploader )[0]; + + // Check success + $this->assertArrayHasKey( 'edit', $apiResult ); + $this->assertArrayHasKey( 'result', $apiResult['edit'] ); + $this->assertEquals( 'Success', $apiResult['edit']['result'] ); + $this->assertArrayHasKey( 'contentmodel', $apiResult['edit'] ); + $this->assertEquals( 'json', $apiResult['edit']['contentmodel'] ); + + $apiResult = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'undo' => $apiResult['edit']['newrevid'] + ], null, $sysop )[0]; + + // Check success + $this->assertArrayHasKey( 'edit', $apiResult ); + $this->assertArrayHasKey( 'result', $apiResult['edit'] ); + $this->assertEquals( 'Success', $apiResult['edit']['result'] ); + $this->assertArrayHasKey( 'contentmodel', $apiResult['edit'] ); + // Check that the contentmodel is back to wikitext now. + $this->assertEquals( 'wikitext', $apiResult['edit']['contentmodel'] ); + } } diff --git a/tests/phpunit/includes/api/ApiErrorFormatterTest.php b/tests/phpunit/includes/api/ApiErrorFormatterTest.php index 18da5afd64..d13b00be2e 100644 --- a/tests/phpunit/includes/api/ApiErrorFormatterTest.php +++ b/tests/phpunit/includes/api/ApiErrorFormatterTest.php @@ -132,7 +132,7 @@ class ApiErrorFormatterTest extends MediaWikiLangTestCase { 'err' => [ [ 'code' => 'mainpage', - 'message' => 'mainpage', + 'key' => 'mainpage', 'params' => [ $I => 'param' ] ], $I => 'error', @@ -142,7 +142,7 @@ class ApiErrorFormatterTest extends MediaWikiLangTestCase { 'string' => [ [ 'code' => 'mainpage', - 'message' => 'mainpage', + 'key' => 'mainpage', 'params' => [ $I => 'param' ] ], $I => 'warning', @@ -154,7 +154,7 @@ class ApiErrorFormatterTest extends MediaWikiLangTestCase { 'errWithData' => [ [ 'code' => 'overriddenCode', - 'message' => 'mainpage', + 'key' => 'mainpage', 'params' => [ $I => 'param' ], 'overriddenData' => true ], @@ -165,7 +165,7 @@ class ApiErrorFormatterTest extends MediaWikiLangTestCase { 'messageWithData' => [ [ 'code' => 'overriddenCode', - 'message' => 'mainpage', + 'key' => 'mainpage', 'params' => [ $I => 'param' ], 'overriddenData' => true ], @@ -174,7 +174,7 @@ class ApiErrorFormatterTest extends MediaWikiLangTestCase { 'message' => [ [ 'code' => 'mainpage', - 'message' => 'mainpage', + 'key' => 'mainpage', 'params' => [ $I => 'param' ] ], $I => 'warning', @@ -182,12 +182,12 @@ class ApiErrorFormatterTest extends MediaWikiLangTestCase { 'foo' => [ [ 'code' => 'mainpage', - 'message' => 'mainpage', + 'key' => 'mainpage', 'params' => [ $I => 'param' ] ], [ 'code' => 'parentheses', - 'message' => 'parentheses', + 'key' => 'parentheses', 'params' => [ 'foobar', $I => 'param' ] ], $I => 'warning', @@ -199,12 +199,12 @@ class ApiErrorFormatterTest extends MediaWikiLangTestCase { 'status' => [ [ 'code' => 'mainpage', - 'message' => 'mainpage', + 'key' => 'mainpage', 'params' => [ $I => 'param' ] ], [ 'code' => 'parentheses', - 'message' => 'parentheses', + 'key' => 'parentheses', 'params' => [ 'foobar', $I => 'param' ] ], $I => 'error', @@ -214,17 +214,17 @@ class ApiErrorFormatterTest extends MediaWikiLangTestCase { 'status' => [ [ 'code' => 'mainpage', - 'message' => 'mainpage', + 'key' => 'mainpage', 'params' => [ $I => 'param' ] ], [ 'code' => 'parentheses', - 'message' => 'parentheses', + 'key' => 'parentheses', 'params' => [ 'foobar', $I => 'param' ] ], [ 'code' => 'overriddenCode', - 'message' => 'mainpage', + 'key' => 'mainpage', 'params' => [ $I => 'param' ], 'overriddenData' => true ], diff --git a/tests/phpunit/includes/api/ApiLoginTest.php b/tests/phpunit/includes/api/ApiLoginTest.php index bcd884eaed..ea8c9ca509 100644 --- a/tests/phpunit/includes/api/ApiLoginTest.php +++ b/tests/phpunit/includes/api/ApiLoginTest.php @@ -17,16 +17,17 @@ class ApiLoginTest extends ApiTestCase { 'wsTokenSecrets' => [ 'login' => 'foobar' ], ]; $data = $this->doApiRequest( [ 'action' => 'login', - 'lgname' => '', 'lgpassword' => self::$users['sysop']->password, + 'lgname' => '', 'lgpassword' => self::$users['sysop']->getPassword(), 'lgtoken' => (string)( new MediaWiki\Session\Token( 'foobar', '' ) ) ], $session ); - $this->assertEquals( 'NoName', $data[0]['login']['result'] ); + $this->assertEquals( 'Failed', $data[0]['login']['result'] ); } public function testApiLoginBadPass() { global $wgServer; $user = self::$users['sysop']; + $userName = $user->getUser()->getName(); $user->getUser()->logout(); if ( !isset( $wgServer ) ) { @@ -34,7 +35,7 @@ class ApiLoginTest extends ApiTestCase { } $ret = $this->doApiRequest( [ "action" => "login", - "lgname" => $user->username, + "lgname" => $userName, "lgpassword" => "bad", ] ); @@ -50,7 +51,7 @@ class ApiLoginTest extends ApiTestCase { [ "action" => "login", "lgtoken" => $token, - "lgname" => $user->username, + "lgname" => $userName, "lgpassword" => "badnowayinhell", ], $ret[2] @@ -61,7 +62,7 @@ class ApiLoginTest extends ApiTestCase { $this->assertNotInternalType( "bool", $result ); $a = $result["login"]["result"]; - $this->assertEquals( "WrongPass", $a ); + $this->assertEquals( 'Failed', $a ); } public function testApiLoginGoodPass() { @@ -72,12 +73,14 @@ class ApiLoginTest extends ApiTestCase { } $user = self::$users['sysop']; + $userName = $user->getUser()->getName(); + $password = $user->getPassword(); $user->getUser()->logout(); $ret = $this->doApiRequest( [ "action" => "login", - "lgname" => $user->username, - "lgpassword" => $user->password, + "lgname" => $userName, + "lgpassword" => $password, ] ); @@ -93,8 +96,8 @@ class ApiLoginTest extends ApiTestCase { [ "action" => "login", "lgtoken" => $token, - "lgname" => $user->username, - "lgpassword" => $user->password, + "lgname" => $userName, + "lgpassword" => $password, ], $ret[2] ); @@ -120,12 +123,14 @@ class ApiLoginTest extends ApiTestCase { $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' ); } $user = self::$users['sysop']; + $userName = $user->getUser()->getName(); + $password = $user->getPassword(); $req = MWHttpRequest::factory( self::$apiUrl . "?action=login&format=xml", [ "method" => "POST", "postData" => [ - "lgname" => $user->username, - "lgpassword" => $user->password + "lgname" => $userName, + "lgpassword" => $password ] ], __METHOD__ @@ -144,8 +149,8 @@ class ApiLoginTest extends ApiTestCase { $req->setData( [ "lgtoken" => $token, - "lgname" => $user->username, - "lgpassword" => $user->password ] ); + "lgname" => $userName, + "lgpassword" => $password ] ); $req->execute(); $cj = $req->getCookieJar(); @@ -160,11 +165,14 @@ class ApiLoginTest extends ApiTestCase { } public function testRunLogin() { - $sysopUser = self::$users['sysop']; + $user = self::$users['sysop']; + $userName = $user->getUser()->getName(); + $password = $user->getPassword(); + $data = $this->doApiRequest( [ 'action' => 'login', - 'lgname' => $sysopUser->username, - 'lgpassword' => $sysopUser->password ] ); + 'lgname' => $userName, + 'lgpassword' => $password ] ); $this->assertArrayHasKey( "login", $data[0] ); $this->assertArrayHasKey( "result", $data[0]['login'] ); @@ -174,13 +182,12 @@ class ApiLoginTest extends ApiTestCase { $data = $this->doApiRequest( [ 'action' => 'login', "lgtoken" => $token, - "lgname" => $sysopUser->username, - "lgpassword" => $sysopUser->password ], $data[2] ); + "lgname" => $userName, + "lgpassword" => $password ], $data[2] ); $this->assertArrayHasKey( "login", $data[0] ); $this->assertArrayHasKey( "result", $data[0]['login'] ); $this->assertEquals( "Success", $data[0]['login']['result'] ); - $this->assertArrayHasKey( 'lgtoken', $data[0]['login'] ); } public function testBotPassword() { @@ -223,11 +230,11 @@ class ApiLoginTest extends ApiTestCase { $centralId = CentralIdLookup::factory()->centralIdFromLocalUser( $user->getUser() ); $this->assertNotEquals( 0, $centralId, 'sanity check' ); + $password = 'ngfhmjm64hv0854493hsj5nncjud2clk'; $passwordFactory = new PasswordFactory(); $passwordFactory->init( RequestContext::getMain()->getConfig() ); // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only - $passwordFactory->setDefaultType( 'A' ); - $pwhash = $passwordFactory->newFromPlaintext( 'foobaz' ); + $passwordHash = $passwordFactory->newFromPlaintext( $password ); $dbw = wfGetDB( DB_MASTER ); $dbw->insert( @@ -235,7 +242,7 @@ class ApiLoginTest extends ApiTestCase { [ 'bp_user' => $centralId, 'bp_app_id' => 'foo', - 'bp_password' => $pwhash->toString(), + 'bp_password' => $passwordHash->toString(), 'bp_token' => '', 'bp_restrictions' => MWRestrictions::newDefault()->toJson(), 'bp_grants' => '["test"]', @@ -243,12 +250,12 @@ class ApiLoginTest extends ApiTestCase { __METHOD__ ); - $lgName = $user->username . BotPassword::getSeparator() . 'foo'; + $lgName = $user->getUser()->getName() . BotPassword::getSeparator() . 'foo'; $ret = $this->doApiRequest( [ 'action' => 'login', 'lgname' => $lgName, - 'lgpassword' => 'foobaz', + 'lgpassword' => $password, ] ); $result = $ret[0]; @@ -263,7 +270,7 @@ class ApiLoginTest extends ApiTestCase { 'action' => 'login', 'lgtoken' => $token, 'lgname' => $lgName, - 'lgpassword' => 'foobaz', + 'lgpassword' => $password, ], $ret[2] ); $result = $ret[0]; diff --git a/tests/phpunit/includes/api/ApiMainTest.php b/tests/phpunit/includes/api/ApiMainTest.php index 06e7962fec..c111949d2f 100644 --- a/tests/phpunit/includes/api/ApiMainTest.php +++ b/tests/phpunit/includes/api/ApiMainTest.php @@ -58,6 +58,29 @@ class ApiMainTest extends ApiTestCase { } } + /** + * Tests the assertuser= functionality + * + * @covers ApiMain::checkAsserts + */ + public function testAssertUser() { + $user = $this->getTestUser()->getUser(); + $this->doApiRequest( [ + 'action' => 'query', + 'assertuser' => $user->getName(), + ], null, null, $user ); + + try { + $this->doApiRequest( [ + 'action' => 'query', + 'assertuser' => $user->getName() . 'X', + ], null, null, $user ); + $this->fail( 'Expected exception not thrown' ); + } catch ( UsageException $e ) { + $this->assertEquals( $e->getCodeString(), 'assertnameduserfailed' ); + } + } + /** * Test if all classes in the main module manager exists */ @@ -253,4 +276,33 @@ class ApiMainTest extends ApiTestCase { ]; } + /** + * @covers ApiMain::lacksSameOriginSecurity + */ + public function testLacksSameOriginSecurity() { + // Basic test + $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) ); + $this->assertFalse( $main->lacksSameOriginSecurity(), 'Basic test, should have security' ); + + // JSONp + $main = new ApiMain( + new FauxRequest( [ 'action' => 'query', 'format' => 'xml', 'callback' => 'foo' ] ) + ); + $this->assertTrue( $main->lacksSameOriginSecurity(), 'JSONp, should lack security' ); + + // Header + $request = new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ); + $request->setHeader( 'TrEaT-As-UnTrUsTeD', '' ); // With falsey value! + $main = new ApiMain( $request ); + $this->assertTrue( $main->lacksSameOriginSecurity(), 'Header supplied, should lack security' ); + + // Hook + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + 'RequestHasSameOriginSecurity' => [ function () { + return false; + } ] + ] ); + $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) ); + $this->assertTrue( $main->lacksSameOriginSecurity(), 'Hook, should lack security' ); + } } diff --git a/tests/phpunit/includes/api/ApiMessageTest.php b/tests/phpunit/includes/api/ApiMessageTest.php index a45015a7b3..8764b4194f 100644 --- a/tests/phpunit/includes/api/ApiMessageTest.php +++ b/tests/phpunit/includes/api/ApiMessageTest.php @@ -5,17 +5,17 @@ */ class ApiMessageTest extends MediaWikiTestCase { - private function compareMessages( $msg, $msg2 ) { + private function compareMessages( Message $msg, Message $msg2 ) { $this->assertSame( $msg->getKey(), $msg2->getKey(), 'getKey' ); $this->assertSame( $msg->getKeysToTry(), $msg2->getKeysToTry(), 'getKeysToTry' ); $this->assertSame( $msg->getParams(), $msg2->getParams(), 'getParams' ); - $this->assertSame( $msg->getFormat(), $msg2->getFormat(), 'getFormat' ); $this->assertSame( $msg->getLanguage(), $msg2->getLanguage(), 'getLanguage' ); $msg = TestingAccessWrapper::newFromObject( $msg ); $msg2 = TestingAccessWrapper::newFromObject( $msg2 ); $this->assertSame( $msg->interface, $msg2->interface, 'interface' ); $this->assertSame( $msg->useDatabase, $msg2->useDatabase, 'useDatabase' ); + $this->assertSame( $msg->format, $msg2->format, 'format' ); $this->assertSame( $msg->title ? $msg->title->getFullText() : null, $msg2->title ? $msg2->title->getFullText() : null, diff --git a/tests/phpunit/includes/api/ApiOpenSearchTest.php b/tests/phpunit/includes/api/ApiOpenSearchTest.php new file mode 100644 index 0000000000..5358f294c6 --- /dev/null +++ b/tests/phpunit/includes/api/ApiOpenSearchTest.php @@ -0,0 +1,66 @@ +replaceSearchEngineConfig(); + $config->expects( $this->any() ) + ->method( 'getSearchTypes' ) + ->will( $this->returnValue( [ 'the one ring' ] ) ); + + $api = $this->createApi(); + $engine = $this->replaceSearchEngine(); + $engine->expects( $this->any() ) + ->method( 'getProfiles' ) + ->will( $this->returnValueMap( [ + [ SearchEngine::COMPLETION_PROFILE_TYPE, $api->getUser(), [ + [ + 'name' => 'normal', + 'desc-message' => 'normal-message', + 'default' => true, + ], + [ + 'name' => 'strict', + 'desc-message' => 'strict-message', + ], + ] ], + ] ) ); + + $params = $api->getAllowedParams(); + + $this->assertArrayNotHasKey( 'offset', $params ); + $this->assertArrayHasKey( 'profile', $params, print_r( $params, true ) ); + $this->assertEquals( 'normal', $params['profile'][ApiBase::PARAM_DFLT] ); + } + + private function replaceSearchEngineConfig() { + $config = $this->getMockBuilder( 'SearchEngineConfig' ) + ->disableOriginalConstructor() + ->getMock(); + $this->setService( 'SearchEngineConfig', $config ); + + return $config; + } + + private function replaceSearchEngine() { + $engine = $this->getMockBuilder( 'SearchEngine' ) + ->disableOriginalConstructor() + ->getMock(); + $engineFactory = $this->getMockBuilder( 'SearchEngineFactory' ) + ->disableOriginalConstructor() + ->getMock(); + $engineFactory->expects( $this->any() ) + ->method( 'create' ) + ->will( $this->returnValue( $engine ) ); + $this->setService( 'SearchEngineFactory', $engineFactory ); + + return $engine; + } + + private function createApi() { + $ctx = new RequestContext(); + $apiMain = new ApiMain( $ctx ); + return new ApiOpenSearch( $apiMain, 'opensearch', '' ); + } +} diff --git a/tests/phpunit/includes/api/ApiPageSetTest.php b/tests/phpunit/includes/api/ApiPageSetTest.php index 367210a2e0..ad1deee5ce 100644 --- a/tests/phpunit/includes/api/ApiPageSetTest.php +++ b/tests/phpunit/includes/api/ApiPageSetTest.php @@ -75,4 +75,25 @@ class ApiPageSetTest extends ApiTestCase { return [ $target, $pageSet ]; } + + public function testHandleNormalization() { + $context = new RequestContext(); + $context->setRequest( new FauxRequest( [ 'titles' => "a|B|a\xcc\x8a" ] ) ); + $main = new ApiMain( $context ); + $pageSet = new ApiPageSet( $main ); + $pageSet->execute(); + + $this->assertSame( + [ 0 => [ 'A' => -1, 'B' => -2, 'Å' => -3 ] ], + $pageSet->getAllTitlesByNamespace() + ); + $this->assertSame( + [ + [ 'fromencoded' => true, 'from' => 'a%CC%8A', 'to' => 'å' ], + [ 'fromencoded' => false, 'from' => 'a', 'to' => 'A' ], + [ 'fromencoded' => false, 'from' => 'å', 'to' => 'Å' ], + ], + $pageSet->getNormalizedTitlesAsResult() + ); + } } diff --git a/tests/phpunit/includes/api/ApiQueryAllPagesTest.php b/tests/phpunit/includes/api/ApiQueryAllPagesTest.php index 3b21ff888d..76872362cd 100644 --- a/tests/phpunit/includes/api/ApiQueryAllPagesTest.php +++ b/tests/phpunit/includes/api/ApiQueryAllPagesTest.php @@ -20,7 +20,11 @@ class ApiQueryAllPagesTest extends ApiTestCase { public function testPrefixNormalizationSearchBug() { $title = Title::newFromText( 'Category:Template:xyz' ); $page = WikiPage::factory( $title ); - $page->doEdit( 'Some text', 'inserting content' ); + + $page->doEditContent( + ContentHandler::makeContent( 'Some text', $page->getTitle() ), + 'inserting content' + ); $result = $this->doApiRequest( [ 'action' => 'query', diff --git a/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php b/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php index f1f9295ef3..eaeb3ae925 100644 --- a/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php +++ b/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php @@ -21,14 +21,12 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { protected function setUp() { parent::setUp(); - self::$users['ApiQueryWatchlistIntegrationTestUser'] - = new TestUser( 'ApiQueryWatchlistIntegrationTestUser' ); - self::$users['ApiQueryWatchlistIntegrationTestUser2'] - = new TestUser( 'ApiQueryWatchlistIntegrationTestUser2' ); + self::$users['ApiQueryWatchlistIntegrationTestUser'] = $this->getMutableTestUser(); + self::$users['ApiQueryWatchlistIntegrationTestUser2'] = $this->getMutableTestUser(); $this->doLogin( 'ApiQueryWatchlistIntegrationTestUser' ); } - private function getTestUser() { + private function getLoggedInTestUser() { return self::$users['ApiQueryWatchlistIntegrationTestUser']->getUser(); } @@ -36,10 +34,6 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { return self::$users['ApiQueryWatchlistIntegrationTestUser2']->getUser(); } - private function getSysopTestUser() { - return self::$users['sysop']->getUser(); - } - private function doPageEdit( User $user, LinkTarget $target, $content, $summary ) { $title = Title::newFromLinkTarget( $target ); $page = WikiPage::factory( $title ); @@ -235,7 +229,10 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } private function getTitleFormatter() { - return new MediaWikiTitleCodec( Language::factory( 'en' ), GenderCache::singleton() ); + return new MediaWikiTitleCodec( + Language::factory( 'en' ), + MediaWikiServices::getInstance()->getGenderCache() + ); } private function getPrefixedText( LinkTarget $target ) { @@ -244,7 +241,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } private function cleanTestUsersWatchlist() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $store = $this->getWatchedItemStore(); $items = $store->getWatchedItemsForUser( $user ); foreach ( $items as $item ) { @@ -257,7 +254,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { // the user with the same user ID as user used here as the test user $this->cleanTestUsersWatchlist(); - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doPageEdit( $user, @@ -290,7 +287,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testIdsPropParameter() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doPageEdit( $user, @@ -311,7 +308,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testTitlePropParameter() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doPageEdits( @@ -351,7 +348,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testFlagsPropParameter() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $normalEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $minorEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPageM' ); $botEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPageB' ); @@ -412,7 +409,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testUserPropParameter() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $userEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $anonEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPageA' ); $this->doPageEdit( @@ -447,7 +444,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testUserIdPropParameter() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $userEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $anonEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPageA' ); $this->doPageEdit( @@ -484,7 +481,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testCommentPropParameter() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doPageEdit( $user, @@ -508,7 +505,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testParsedCommentPropParameter() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doPageEdit( $user, @@ -532,7 +529,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testTimestampPropParameter() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doPageEdit( $user, @@ -551,7 +548,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testSizesPropParameter() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doPageEdit( $user, @@ -585,7 +582,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { 'Create the page' ); $store = $this->getWatchedItemStore(); - $store->addWatch( $this->getTestUser(), $target ); + $store->addWatch( $this->getLoggedInTestUser(), $target ); $store->updateNotificationTimestamp( $otherUser, $target, @@ -620,7 +617,8 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testPatrolPropParameter() { - $user = $this->getSysopTestUser(); + $testUser = static::getTestSysop(); + $user = $testUser->getUser(); $this->setupPatrolledSpecificFixtures( $user ); $result = $this->doListWatchlistRequest( [ 'wlprop' => 'patrol', ], $user ); @@ -639,7 +637,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { private function createPageAndDeleteIt( LinkTarget $target ) { $this->doPageEdit( - $this->getTestUser(), + $this->getLoggedInTestUser(), $target, 'Some Content', 'Create the page that will be deleted' @@ -651,7 +649,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $this->createPageAndDeleteIt( $target ); - $this->watchPages( $this->getTestUser(), [ $target ] ); + $this->watchPages( $this->getLoggedInTestUser(), [ $target ] ); $result = $this->doListWatchlistRequest( [ 'wlprop' => 'loginfo', ] ); @@ -671,7 +669,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testEmptyPropParameter() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doPageEdit( $user, @@ -694,7 +692,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testNamespaceParam() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doPageEdits( @@ -729,7 +727,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testUserParam() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $otherUser = $this->getNonLoggedInTestUser(); $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); @@ -766,7 +764,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testExcludeUserParam() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $otherUser = $this->getNonLoggedInTestUser(); $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); @@ -803,7 +801,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testShowMinorParams() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doPageEdits( $user, @@ -823,8 +821,13 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { ); $this->watchPages( $user, [ $target ] ); - $resultMinor = $this->doListWatchlistRequest( [ 'wlshow' => 'minor', 'wlprop' => 'flags' ] ); - $resultNotMinor = $this->doListWatchlistRequest( [ 'wlshow' => '!minor', 'wlprop' => 'flags' ] ); + $resultMinor = $this->doListWatchlistRequest( [ + 'wlshow' => WatchedItemQueryService::FILTER_MINOR, + 'wlprop' => 'flags' + ] ); + $resultNotMinor = $this->doListWatchlistRequest( [ + 'wlshow' => WatchedItemQueryService::FILTER_NOT_MINOR, 'wlprop' => 'flags' + ] ); $this->assertArraySubsetsEqual( $this->getItemsFromApiResponse( $resultMinor ), @@ -837,7 +840,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testShowBotParams() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doBotPageEdit( $user, @@ -847,8 +850,12 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { ); $this->watchPages( $user, [ $target ] ); - $resultBot = $this->doListWatchlistRequest( [ 'wlshow' => 'bot' ] ); - $resultNotBot = $this->doListWatchlistRequest( [ 'wlshow' => '!bot' ] ); + $resultBot = $this->doListWatchlistRequest( [ + 'wlshow' => WatchedItemQueryService::FILTER_BOT + ] ); + $resultNotBot = $this->doListWatchlistRequest( [ + 'wlshow' => WatchedItemQueryService::FILTER_NOT_BOT + ] ); $this->assertArraySubsetsEqual( $this->getItemsFromApiResponse( $resultBot ), @@ -861,7 +868,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testShowAnonParams() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doAnonPageEdit( $target, @@ -872,11 +879,11 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { $resultAnon = $this->doListWatchlistRequest( [ 'wlprop' => 'user', - 'wlshow' => 'anon' + 'wlshow' => WatchedItemQueryService::FILTER_ANON ] ); $resultNotAnon = $this->doListWatchlistRequest( [ 'wlprop' => 'user', - 'wlshow' => '!anon' + 'wlshow' => WatchedItemQueryService::FILTER_NOT_ANON ] ); $this->assertArraySubsetsEqual( @@ -890,7 +897,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testShowUnreadParams() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $otherUser = $this->getNonLoggedInTestUser(); $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); @@ -916,11 +923,11 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { $resultUnread = $this->doListWatchlistRequest( [ 'wlprop' => 'notificationtimestamp|title', - 'wlshow' => 'unread' + 'wlshow' => WatchedItemQueryService::FILTER_UNREAD ] ); $resultNotUnread = $this->doListWatchlistRequest( [ 'wlprop' => 'notificationtimestamp|title', - 'wlshow' => '!unread' + 'wlshow' => WatchedItemQueryService::FILTER_NOT_UNREAD ] ); $this->assertEquals( @@ -948,16 +955,16 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testShowPatrolledParams() { - $user = $this->getSysopTestUser(); + $user = static::getTestSysop()->getUser(); $this->setupPatrolledSpecificFixtures( $user ); $resultPatrolled = $this->doListWatchlistRequest( [ 'wlprop' => 'patrol', - 'wlshow' => 'patrolled' + 'wlshow' => WatchedItemQueryService::FILTER_PATROLLED ], $user ); $resultNotPatrolled = $this->doListWatchlistRequest( [ 'wlprop' => 'patrol', - 'wlshow' => '!patrolled' + 'wlshow' => WatchedItemQueryService::FILTER_NOT_PATROLLED ], $user ); $this->assertEquals( @@ -974,7 +981,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testNewAndEditTypeParameters() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doPageEdits( @@ -1025,7 +1032,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testLogTypeParameters() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); $this->createPageAndDeleteIt( $subjectTarget ); @@ -1093,7 +1100,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testExternalTypeParameters() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doPageEdit( @@ -1129,7 +1136,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testCategorizeTypeParameter() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $categoryTarget = new TitleValue( NS_CATEGORY, 'ApiQueryWatchlistIntegrationTestCategory' ); $this->doPageEdits( @@ -1180,7 +1187,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testLimitParam() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $target1 = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $target2 = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); $target3 = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage2' ); @@ -1249,7 +1256,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testAllRevParam() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doPageEdits( $user, @@ -1299,7 +1306,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testDirParams() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doPageEdits( @@ -1355,7 +1362,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testStartEndParams() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doPageEdit( $user, @@ -1390,7 +1397,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testContinueParam() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $target1 = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $target2 = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); $target3 = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage2' ); @@ -1456,7 +1463,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { public function testOwnerAndTokenParams() { $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doPageEdit( - $this->getTestUser(), + $this->getLoggedInTestUser(), $target, 'Some Content', 'Create the page' @@ -1509,7 +1516,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testGeneratorWatchlistPropInfo_returnsWatchedPages() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doPageEdit( $user, @@ -1541,7 +1548,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } public function testGeneratorWatchlistPropRevisions_returnsWatchedItemsRevisions() { - $user = $this->getTestUser(); + $user = $this->getLoggedInTestUser(); $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); $this->doPageEdits( $user, diff --git a/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php b/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php new file mode 100644 index 0000000000..d6f315d5b3 --- /dev/null +++ b/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php @@ -0,0 +1,543 @@ +getMutableTestUser(); + self::$users['ApiQueryWatchlistRawIntegrationTestUser2'] + = $this->getMutableTestUser(); + $this->doLogin( 'ApiQueryWatchlistRawIntegrationTestUser' ); + } + + private function getLoggedInTestUser() { + return self::$users['ApiQueryWatchlistRawIntegrationTestUser']->getUser(); + } + + private function getNotLoggedInTestUser() { + return self::$users['ApiQueryWatchlistRawIntegrationTestUser2']->getUser(); + } + + private function getWatchedItemStore() { + return MediaWikiServices::getInstance()->getWatchedItemStore(); + } + + private function doListWatchlistRawRequest( array $params = [] ) { + return $this->doApiRequest( array_merge( + [ 'action' => 'query', 'list' => 'watchlistraw' ], + $params + ) ); + } + + private function doGeneratorWatchlistRawRequest( array $params = [] ) { + return $this->doApiRequest( array_merge( + [ 'action' => 'query', 'generator' => 'watchlistraw' ], + $params + ) ); + } + + private function getItemsFromApiResponse( array $response ) { + return $response[0]['watchlistraw']; + } + + public function testListWatchlistRaw_returnsWatchedItems() { + $store = $this->getWatchedItemStore(); + $store->addWatch( + $this->getLoggedInTestUser(), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' ) + ); + + $result = $this->doListWatchlistRawRequest(); + + $this->assertArrayHasKey( 'watchlistraw', $result[0] ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage', + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testPropChanged_addsNotificationTimestamp() { + $target = new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' ); + $otherUser = $this->getNotLoggedInTestUser(); + + $store = $this->getWatchedItemStore(); + + $store->addWatch( $this->getLoggedInTestUser(), $target ); + $store->updateNotificationTimestamp( + $otherUser, + $target, + '20151212010101' + ); + + $result = $this->doListWatchlistRawRequest( [ 'wrprop' => 'changed' ] ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage', + 'changed' => '2015-12-12T01:01:01Z', + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testNamespaceParam() { + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' ), + new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage' ), + ] ); + + $result = $this->doListWatchlistRawRequest( [ 'wrnamespace' => '0' ] ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage', + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testShowChangedParams() { + $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' ); + $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage' ); + $otherUser = $this->getNotLoggedInTestUser(); + + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + $subjectTarget, + $talkTarget, + ] ); + $store->updateNotificationTimestamp( + $otherUser, + $subjectTarget, + '20151212010101' + ); + + $resultChanged = $this->doListWatchlistRawRequest( + [ 'wrprop' => 'changed', 'wrshow' => WatchedItemQueryService::FILTER_CHANGED ] + ); + $resultNotChanged = $this->doListWatchlistRawRequest( + [ 'wrprop' => 'changed', 'wrshow' => WatchedItemQueryService::FILTER_NOT_CHANGED ] + ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage', + 'changed' => '2015-12-12T01:01:01Z', + ], + ], + $this->getItemsFromApiResponse( $resultChanged ) + ); + + $this->assertEquals( + [ + [ + 'ns' => 1, + 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage', + ], + ], + $this->getItemsFromApiResponse( $resultNotChanged ) + ); + } + + public function testLimitParam() { + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ), + ] ); + + $resultWithoutLimit = $this->doListWatchlistRawRequest(); + $resultWithLimit = $this->doListWatchlistRawRequest( [ 'wrlimit' => 2 ] ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1', + ], + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2', + ], + [ + 'ns' => 1, + 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1', + ], + ], + $this->getItemsFromApiResponse( $resultWithoutLimit ) + ); + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1', + ], + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2', + ], + ], + $this->getItemsFromApiResponse( $resultWithLimit ) + ); + + $this->assertArrayNotHasKey( 'continue', $resultWithoutLimit[0] ); + $this->assertArrayHasKey( 'continue', $resultWithLimit[0] ); + $this->assertArrayHasKey( 'wrcontinue', $resultWithLimit[0]['continue'] ); + } + + public function testDirParams() { + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ), + ] ); + + $resultDirAsc = $this->doListWatchlistRawRequest( [ 'wrdir' => 'ascending' ] ); + $resultDirDesc = $this->doListWatchlistRawRequest( [ 'wrdir' => 'descending' ] ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1', + ], + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2', + ], + [ + 'ns' => 1, + 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1', + ], + ], + $this->getItemsFromApiResponse( $resultDirAsc ) + ); + + $this->assertEquals( + [ + [ + 'ns' => 1, + 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1', + ], + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2', + ], + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1', + ], + ], + $this->getItemsFromApiResponse( $resultDirDesc ) + ); + } + + public function testAscendingIsDefaultOrder() { + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ), + ] ); + + $resultNoDir = $this->doListWatchlistRawRequest(); + $resultAscDir = $this->doListWatchlistRawRequest( [ 'wrdir' => 'ascending' ] ); + + $this->assertEquals( + $this->getItemsFromApiResponse( $resultNoDir ), + $this->getItemsFromApiResponse( $resultAscDir ) + ); + } + + public function testFromTitleParam() { + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage3' ), + ] ); + + $result = $this->doListWatchlistRawRequest( [ + 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage2', + ] ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2', + ], + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3', + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testToTitleParam() { + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage3' ), + ] ); + + $result = $this->doListWatchlistRawRequest( [ + 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage2', + ] ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1', + ], + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2', + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testContinueParam() { + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage3' ), + ] ); + + $firstResult = $this->doListWatchlistRawRequest( [ 'wrlimit' => 2 ] ); + $continuationParam = $firstResult[0]['continue']['wrcontinue']; + + $this->assertEquals( '0|ApiQueryWatchlistRawIntegrationTestPage3', $continuationParam ); + + $continuedResult = $this->doListWatchlistRawRequest( [ 'wrcontinue' => $continuationParam ] ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3', + ] + ], + $this->getItemsFromApiResponse( $continuedResult ) + ); + } + + public function fromTitleToTitleContinueComboProvider() { + return [ + [ + [ + 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1', + 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage2', + ], + [ + [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1' ], + [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2' ], + ], + ], + [ + [ + 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1', + 'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage3', + ], + [ + [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3' ], + ], + ], + [ + [ + 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage3', + 'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage2', + ], + [ + [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2' ], + [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3' ], + ], + ], + [ + [ + 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1', + 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage3', + 'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage3', + ], + [ + [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3' ], + ], + ], + ]; + } + + /** + * @dataProvider fromTitleToTitleContinueComboProvider + */ + public function testFromTitleToTitleContinueCombo( array $params, array $expectedItems ) { + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage3' ), + ] ); + + $result = $this->doListWatchlistRawRequest( $params ); + + $this->assertEquals( $expectedItems, $this->getItemsFromApiResponse( $result ) ); + } + + public function fromTitleToTitleContinueSelfContradictoryComboProvider() { + return [ + [ + [ + 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage2', + 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage1', + ] + ], + [ + [ + 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1', + 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage2', + 'wrdir' => 'descending', + ] + ], + [ + [ + 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage1', + 'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage2', + ] + ], + ]; + } + + /** + * @dataProvider fromTitleToTitleContinueSelfContradictoryComboProvider + */ + public function testFromTitleToTitleContinueSelfContradictoryCombo( array $params ) { + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ), + ] ); + + $result = $this->doListWatchlistRawRequest( $params ); + + $this->assertEmpty( $this->getItemsFromApiResponse( $result ) ); + $this->assertArrayNotHasKey( 'continue', $result[0] ); + } + + public function testOwnerAndTokenParams() { + $otherUser = $this->getNotLoggedInTestUser(); + $otherUser->setOption( 'watchlisttoken', '1234567890' ); + $otherUser->saveSettings(); + + $store = $this->getWatchedItemStore(); + $store->addWatchBatchForUser( $otherUser, [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + ] ); + + ObjectCache::getMainWANInstance()->clearProcessCache(); + $result = $this->doListWatchlistRawRequest( [ + 'wrowner' => $otherUser->getName(), + 'wrtoken' => '1234567890', + ] ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1', + ], + [ + 'ns' => 1, + 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1', + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testOwnerAndTokenParams_wrongToken() { + $otherUser = $this->getNotLoggedInTestUser(); + $otherUser->setOption( 'watchlisttoken', '1234567890' ); + $otherUser->saveSettings(); + + $this->setExpectedException( UsageException::class, 'Incorrect watchlist token provided' ); + + $this->doListWatchlistRawRequest( [ + 'wrowner' => $otherUser->getName(), + 'wrtoken' => 'wrong-token', + ] ); + } + + public function testOwnerAndTokenParams_userHasNoWatchlistToken() { + $this->setExpectedException( UsageException::class, 'Incorrect watchlist token provided' ); + + $this->doListWatchlistRawRequest( [ + 'wrowner' => $this->getNotLoggedInTestUser()->getName(), + 'wrtoken' => 'some-watchlist-token', + ] ); + } + + public function testGeneratorWatchlistRawPropInfo_returnsWatchedItems() { + $store = $this->getWatchedItemStore(); + $store->addWatch( + $this->getLoggedInTestUser(), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' ) + ); + + $result = $this->doGeneratorWatchlistRawRequest( [ 'prop' => 'info' ] ); + + $this->assertArrayHasKey( 'query', $result[0] ); + $this->assertArrayHasKey( 'pages', $result[0]['query'] ); + $this->assertCount( 1, $result[0]['query']['pages'] ); + + // $result[0]['query']['pages'] uses page ids as keys + $item = array_values( $result[0]['query']['pages'] )[0]; + + $this->assertEquals( 0, $item['ns'] ); + $this->assertEquals( 'ApiQueryWatchlistRawIntegrationTestPage', $item['title'] ); + } + +} diff --git a/tests/phpunit/includes/api/ApiResultTest.php b/tests/phpunit/includes/api/ApiResultTest.php index 7c0063d404..98e24fb666 100644 --- a/tests/phpunit/includes/api/ApiResultTest.php +++ b/tests/phpunit/includes/api/ApiResultTest.php @@ -1230,7 +1230,6 @@ class ApiResultTest extends MediaWikiTestCase { ], ], ]; - } /** @@ -1323,350 +1322,6 @@ class ApiResultTest extends MediaWikiTestCase { ], ApiResult::addMetadataToResultVars( $arr ) ); } - /** - * @covers ApiResult - */ - public function testDeprecatedFunctions() { - // Ignore ApiResult deprecation warnings during this test - set_error_handler( function ( $errno, $errstr ) use ( &$warnings ) { - if ( preg_match( '/Use of ApiResult::\S+ was deprecated in MediaWiki \d+.\d+\./', $errstr ) ) { - return true; - } - if ( preg_match( '/Use of ApiMain to ApiResult::__construct ' . - 'was deprecated in MediaWiki \d+.\d+\./', $errstr ) ) { - return true; - } - return false; - } ); - $reset = new ScopedCallback( 'restore_error_handler' ); - - $context = new DerivativeContext( RequestContext::getMain() ); - $context->setConfig( new HashConfig( [ - 'APIModules' => [], - 'APIFormatModules' => [], - 'APIMaxResultSize' => 42, - ] ) ); - $main = new ApiMain( $context ); - $result = TestingAccessWrapper::newFromObject( new ApiResult( $main ) ); - $this->assertSame( 42, $result->maxSize ); - $this->assertSame( $main->getErrorFormatter(), $result->errorFormatter ); - $this->assertSame( $main, $result->mainForContinuation ); - - $result = new ApiResult( 8388608 ); - - $result->addContentValue( null, 'test', 'content' ); - $result->addContentValue( [ 'foo', 'bar' ], 'test', 'content' ); - $result->addIndexedTagName( null, 'itn' ); - $result->addSubelementsList( null, [ 'sub' ] ); - $this->assertSame( [ - 'foo' => [ - 'bar' => [ - '*' => 'content', - ], - ], - '*' => 'content', - ], $result->getData() ); - - $arr = []; - ApiResult::setContent( $arr, 'value' ); - ApiResult::setContent( $arr, 'value2', 'foobar' ); - $this->assertSame( [ - ApiResult::META_CONTENT => 'content', - 'content' => 'value', - 'foobar' => [ - ApiResult::META_CONTENT => 'content', - 'content' => 'value2', - ], - ], $arr ); - - $result = new ApiResult( 3 ); - $formatter = new ApiErrorFormatter_BackCompat( $result ); - $result->setErrorFormatter( $formatter ); - $result->disableSizeCheck(); - $this->assertTrue( $result->addValue( null, 'foo', '1234567890' ) ); - $result->enableSizeCheck(); - $this->assertSame( 0, $result->getSize() ); - $this->assertFalse( $result->addValue( null, 'foo', '1234567890' ) ); - - $arr = [ 'foo' => [ 'bar' => 1 ] ]; - $result->setIndexedTagName_recursive( $arr, 'itn' ); - $this->assertSame( [ - 'foo' => [ - 'bar' => 1, - ApiResult::META_INDEXED_TAG_NAME => 'itn' - ], - ], $arr ); - - $status = Status::newGood(); - $status->fatal( 'parentheses', '1' ); - $status->fatal( 'parentheses', '2' ); - $status->warning( 'parentheses', '3' ); - $status->warning( 'parentheses', '4' ); - $this->assertSame( [ - [ - 'type' => 'error', - 'message' => 'parentheses', - 'params' => [ - 0 => '1', - ApiResult::META_INDEXED_TAG_NAME => 'param', - ], - ], - [ - 'type' => 'error', - 'message' => 'parentheses', - 'params' => [ - 0 => '2', - ApiResult::META_INDEXED_TAG_NAME => 'param', - ], - ], - ApiResult::META_INDEXED_TAG_NAME => 'error', - ], $result->convertStatusToArray( $status, 'error' ) ); - $this->assertSame( [ - [ - 'type' => 'warning', - 'message' => 'parentheses', - 'params' => [ - 0 => '3', - ApiResult::META_INDEXED_TAG_NAME => 'param', - ], - ], - [ - 'type' => 'warning', - 'message' => 'parentheses', - 'params' => [ - 0 => '4', - ApiResult::META_INDEXED_TAG_NAME => 'param', - ], - ], - ApiResult::META_INDEXED_TAG_NAME => 'warning', - ], $result->convertStatusToArray( $status, 'warning' ) ); - } - - /** - * @covers ApiResult - */ - public function testDeprecatedContinuation() { - // Ignore ApiResult deprecation warnings during this test - set_error_handler( function ( $errno, $errstr ) use ( &$warnings ) { - if ( preg_match( '/Use of ApiResult::\S+ was deprecated in MediaWiki \d+.\d+\./', $errstr ) ) { - return true; - } - return false; - } ); - - $reset = new ScopedCallback( 'restore_error_handler' ); - $allModules = [ - new MockApiQueryBase( 'mock1' ), - new MockApiQueryBase( 'mock2' ), - new MockApiQueryBase( 'mocklist' ), - ]; - $generator = new MockApiQueryBase( 'generator' ); - - $main = new ApiMain( RequestContext::getMain() ); - $result = new ApiResult( 8388608 ); - $result->setMainForContinuation( $main ); - $ret = $result->beginContinuation( null, $allModules, [ 'mock1', 'mock2' ] ); - $this->assertSame( [ false, $allModules ], $ret ); - $result->setContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] ); - $result->setContinueParam( $allModules[2], 'mlcontinue', 2 ); - $result->setGeneratorContinueParam( $generator, 'gcontinue', 3 ); - $result->endContinuation( 'raw' ); - $result->endContinuation( 'standard' ); - $this->assertSame( [ - 'mlcontinue' => 2, - 'm1continue' => '1|2', - 'continue' => '||mock2', - ], $result->getResultData( 'continue' ) ); - $this->assertSame( null, $result->getResultData( 'batchcomplete' ) ); - $this->assertSame( [ - 'mock1' => [ 'm1continue' => '1|2' ], - 'mocklist' => [ 'mlcontinue' => 2 ], - 'generator' => [ 'gcontinue' => 3 ], - ], $result->getResultData( 'query-continue' ) ); - $main->setContinuationManager( null ); - - $result = new ApiResult( 8388608 ); - $result->setMainForContinuation( $main ); - $ret = $result->beginContinuation( null, $allModules, [ 'mock1', 'mock2' ] ); - $this->assertSame( [ false, $allModules ], $ret ); - $result->setContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] ); - $result->setGeneratorContinueParam( $generator, 'gcontinue', [ 3, 4 ] ); - $result->endContinuation( 'raw' ); - $result->endContinuation( 'standard' ); - $this->assertSame( [ - 'm1continue' => '1|2', - 'continue' => '||mock2|mocklist', - ], $result->getResultData( 'continue' ) ); - $this->assertSame( null, $result->getResultData( 'batchcomplete' ) ); - $this->assertSame( [ - 'mock1' => [ 'm1continue' => '1|2' ], - 'generator' => [ 'gcontinue' => '3|4' ], - ], $result->getResultData( 'query-continue' ) ); - $main->setContinuationManager( null ); - - $result = new ApiResult( 8388608 ); - $result->setMainForContinuation( $main ); - $ret = $result->beginContinuation( null, $allModules, [ 'mock1', 'mock2' ] ); - $this->assertSame( [ false, $allModules ], $ret ); - $result->setContinueParam( $allModules[2], 'mlcontinue', 2 ); - $result->setGeneratorContinueParam( $generator, 'gcontinue', 3 ); - $result->endContinuation( 'raw' ); - $result->endContinuation( 'standard' ); - $this->assertSame( [ - 'mlcontinue' => 2, - 'gcontinue' => 3, - 'continue' => 'gcontinue||', - ], $result->getResultData( 'continue' ) ); - $this->assertSame( true, $result->getResultData( 'batchcomplete' ) ); - $this->assertSame( [ - 'mocklist' => [ 'mlcontinue' => 2 ], - 'generator' => [ 'gcontinue' => 3 ], - ], $result->getResultData( 'query-continue' ) ); - $main->setContinuationManager( null ); - - $result = new ApiResult( 8388608 ); - $result->setMainForContinuation( $main ); - $ret = $result->beginContinuation( null, $allModules, [ 'mock1', 'mock2' ] ); - $this->assertSame( [ false, $allModules ], $ret ); - $result->setGeneratorContinueParam( $generator, 'gcontinue', 3 ); - $result->endContinuation( 'raw' ); - $result->endContinuation( 'standard' ); - $this->assertSame( [ - 'gcontinue' => 3, - 'continue' => 'gcontinue||mocklist', - ], $result->getResultData( 'continue' ) ); - $this->assertSame( true, $result->getResultData( 'batchcomplete' ) ); - $this->assertSame( [ - 'generator' => [ 'gcontinue' => 3 ], - ], $result->getResultData( 'query-continue' ) ); - $main->setContinuationManager( null ); - - $result = new ApiResult( 8388608 ); - $result->setMainForContinuation( $main ); - $ret = $result->beginContinuation( null, $allModules, [ 'mock1', 'mock2' ] ); - $this->assertSame( [ false, $allModules ], $ret ); - $result->setContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] ); - $result->setContinueParam( $allModules[2], 'mlcontinue', 2 ); - $result->endContinuation( 'raw' ); - $result->endContinuation( 'standard' ); - $this->assertSame( [ - 'mlcontinue' => 2, - 'm1continue' => '1|2', - 'continue' => '||mock2', - ], $result->getResultData( 'continue' ) ); - $this->assertSame( null, $result->getResultData( 'batchcomplete' ) ); - $this->assertSame( [ - 'mock1' => [ 'm1continue' => '1|2' ], - 'mocklist' => [ 'mlcontinue' => 2 ], - ], $result->getResultData( 'query-continue' ) ); - $main->setContinuationManager( null ); - - $result = new ApiResult( 8388608 ); - $result->setMainForContinuation( $main ); - $ret = $result->beginContinuation( null, $allModules, [ 'mock1', 'mock2' ] ); - $this->assertSame( [ false, $allModules ], $ret ); - $result->setContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] ); - $result->endContinuation( 'raw' ); - $result->endContinuation( 'standard' ); - $this->assertSame( [ - 'm1continue' => '1|2', - 'continue' => '||mock2|mocklist', - ], $result->getResultData( 'continue' ) ); - $this->assertSame( null, $result->getResultData( 'batchcomplete' ) ); - $this->assertSame( [ - 'mock1' => [ 'm1continue' => '1|2' ], - ], $result->getResultData( 'query-continue' ) ); - $main->setContinuationManager( null ); - - $result = new ApiResult( 8388608 ); - $result->setMainForContinuation( $main ); - $ret = $result->beginContinuation( null, $allModules, [ 'mock1', 'mock2' ] ); - $this->assertSame( [ false, $allModules ], $ret ); - $result->setContinueParam( $allModules[2], 'mlcontinue', 2 ); - $result->endContinuation( 'raw' ); - $result->endContinuation( 'standard' ); - $this->assertSame( [ - 'mlcontinue' => 2, - 'continue' => '-||mock1|mock2', - ], $result->getResultData( 'continue' ) ); - $this->assertSame( true, $result->getResultData( 'batchcomplete' ) ); - $this->assertSame( [ - 'mocklist' => [ 'mlcontinue' => 2 ], - ], $result->getResultData( 'query-continue' ) ); - $main->setContinuationManager( null ); - - $result = new ApiResult( 8388608 ); - $result->setMainForContinuation( $main ); - $ret = $result->beginContinuation( null, $allModules, [ 'mock1', 'mock2' ] ); - $this->assertSame( [ false, $allModules ], $ret ); - $result->endContinuation( 'raw' ); - $result->endContinuation( 'standard' ); - $this->assertSame( null, $result->getResultData( 'continue' ) ); - $this->assertSame( true, $result->getResultData( 'batchcomplete' ) ); - $this->assertSame( null, $result->getResultData( 'query-continue' ) ); - $main->setContinuationManager( null ); - - $result = new ApiResult( 8388608 ); - $result->setMainForContinuation( $main ); - $ret = $result->beginContinuation( '||mock2', $allModules, [ 'mock1', 'mock2' ] ); - $this->assertSame( - [ false, array_values( array_diff_key( $allModules, [ 1 => 1 ] ) ) ], - $ret - ); - $main->setContinuationManager( null ); - - $result = new ApiResult( 8388608 ); - $result->setMainForContinuation( $main ); - $ret = $result->beginContinuation( '-||', $allModules, [ 'mock1', 'mock2' ] ); - $this->assertSame( - [ true, array_values( array_diff_key( $allModules, [ 0 => 0, 1 => 1 ] ) ) ], - $ret - ); - $main->setContinuationManager( null ); - - $result = new ApiResult( 8388608 ); - $result->setMainForContinuation( $main ); - try { - $result->beginContinuation( 'foo', $allModules, [ 'mock1', 'mock2' ] ); - $this->fail( 'Expected exception not thrown' ); - } catch ( UsageException $ex ) { - $this->assertSame( - 'Invalid continue param. You should pass the original value returned by the previous query', - $ex->getMessage(), - 'Expected exception' - ); - } - $main->setContinuationManager( null ); - - $result = new ApiResult( 8388608 ); - $result->setMainForContinuation( $main ); - $result->beginContinuation( '||mock2', array_slice( $allModules, 0, 2 ), - [ 'mock1', 'mock2' ] ); - try { - $result->setContinueParam( $allModules[1], 'm2continue', 1 ); - $this->fail( 'Expected exception not thrown' ); - } catch ( UnexpectedValueException $ex ) { - $this->assertSame( - 'Module \'mock2\' was not supposed to have been executed, but it was executed anyway', - $ex->getMessage(), - 'Expected exception' - ); - } - try { - $result->setContinueParam( $allModules[2], 'mlcontinue', 1 ); - $this->fail( 'Expected exception not thrown' ); - } catch ( UnexpectedValueException $ex ) { - $this->assertSame( - 'Module \'mocklist\' called ApiContinuationManager::addContinueParam ' . - 'but was not passed to ApiContinuationManager::__construct', - $ex->getMessage(), - 'Expected exception' - ); - } - $main->setContinuationManager( null ); - - } - public function testObjectSerialization() { $arr = []; ApiResult::setValue( $arr, 'foo', (object)[ 'a' => 1, 'b' => 2 ] ); @@ -1724,7 +1379,6 @@ class ApiResultTest extends MediaWikiTestCase { 'two' => 2, ], $arr['foo'] ); } - } class ApiResultTestStringifiableObject { diff --git a/tests/phpunit/includes/api/ApiRevisionDeleteTest.php b/tests/phpunit/includes/api/ApiRevisionDeleteTest.php index 6359983d6e..d8282be159 100644 --- a/tests/phpunit/includes/api/ApiRevisionDeleteTest.php +++ b/tests/phpunit/includes/api/ApiRevisionDeleteTest.php @@ -25,7 +25,6 @@ class ApiRevisionDeleteTest extends ApiTestCase { $this->revs[] = Title::newFromText( self::$page ) ->getLatestRevID( Title::GAID_FOR_UPDATE ); } - } public function testHidingRevisions() { diff --git a/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php b/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php new file mode 100644 index 0000000000..ef4f5139e4 --- /dev/null +++ b/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php @@ -0,0 +1,52 @@ +doLogin( __CLASS__ ); + } + + public function testStuff() { + $user = self::$users[__CLASS__]->getUser(); + $page = WikiPage::factory( Title::newFromText( 'UTPage' ) ); + + $user->addWatch( $page->getTitle() ); + + $result = $this->doApiRequestWithToken( + [ + 'action' => 'setnotificationtimestamp', + 'timestamp' => '20160101020202', + 'pageids' => $page->getId(), + ], + null, + $user + ); + + $this->assertEquals( + [ + 'batchcomplete' => true, + 'setnotificationtimestamp' => [ + [ 'ns' => 0, 'title' => 'UTPage', 'notificationtimestamp' => '2016-01-01T02:02:02Z' ] + ], + ], + $result[0] + ); + + $watchedItemStore = MediaWikiServices::getInstance()->getWatchedItemStore(); + $this->assertEquals( + $watchedItemStore->getNotificationTimestampsBatch( $user, [ $page->getTitle() ] ), + [ [ 'UTPage' => '20160101020202' ] ] + ); + } + +} diff --git a/tests/phpunit/includes/api/ApiStashEditTest.php b/tests/phpunit/includes/api/ApiStashEditTest.php new file mode 100644 index 0000000000..e2462c6138 --- /dev/null +++ b/tests/phpunit/includes/api/ApiStashEditTest.php @@ -0,0 +1,28 @@ +doLogin(); + $apiResult = $this->doApiRequestWithToken( + [ + 'action' => 'stashedit', + 'title' => 'ApistashEdit_Page', + 'contentmodel' => 'wikitext', + 'contentformat' => 'text/x-wiki', + 'text' => 'Text for ' . __METHOD__ . ' page', + 'baserevid' => 0, + ] + ); + $apiResult = $apiResult[0]; + $this->assertArrayHasKey( 'stashedit', $apiResult ); + $this->assertEquals( 'stashed', $apiResult['stashedit']['status'] ); + } + +} diff --git a/tests/phpunit/includes/api/ApiTestCase.php b/tests/phpunit/includes/api/ApiTestCase.php index 246ea3d235..7e1f9d8775 100644 --- a/tests/phpunit/includes/api/ApiTestCase.php +++ b/tests/phpunit/includes/api/ApiTestCase.php @@ -8,11 +8,6 @@ abstract class ApiTestCase extends MediaWikiLangTestCase { */ protected $apiContext; - /** - * @var array - */ - protected $tablesUsed = [ 'user', 'user_groups', 'user_properties' ]; - protected function setUp() { global $wgServer; @@ -22,24 +17,14 @@ abstract class ApiTestCase extends MediaWikiLangTestCase { ApiQueryInfo::resetTokenCache(); // tokens are invalid because we cleared the session self::$users = [ - 'sysop' => new TestUser( - 'Apitestsysop', - 'Api Test Sysop', - 'api_test_sysop@example.com', - [ 'sysop' ] - ), - 'uploader' => new TestUser( - 'Apitestuser', - 'Api Test User', - 'api_test_user@example.com', - [] - ) + 'sysop' => static::getTestSysop(), + 'uploader' => static::getTestUser(), ]; $this->setMwGlobals( [ - 'wgAuth' => new AuthPlugin, + 'wgAuth' => new MediaWiki\Auth\AuthManagerAuthPlugin, 'wgRequest' => new FauxRequest( [] ), - 'wgUser' => self::$users['sysop']->user, + 'wgUser' => self::$users['sysop']->getUser(), ] ); $this->apiContext = new ApiTestContext(); @@ -101,6 +86,7 @@ abstract class ApiTestCase extends MediaWikiLangTestCase { $wgRequest = new FauxRequest( $params, true, $session ); RequestContext::getMain()->setRequest( $wgRequest ); RequestContext::getMain()->setUser( $wgUser ); + MediaWiki\Auth\AuthManager::resetCache(); // set up local environment $context = $this->apiContext->newTestContext( $wgRequest, $wgUser ); @@ -161,15 +147,19 @@ abstract class ApiTestCase extends MediaWikiLangTestCase { } } - protected function doLogin( $user = 'sysop' ) { - if ( !array_key_exists( $user, self::$users ) ) { - throw new MWException( "Can not log in to undefined user $user" ); + protected function doLogin( $testUser = 'sysop' ) { + if ( $testUser === null ) { + $testUser = static::getTestSysop(); + } elseif ( is_string( $testUser ) && array_key_exists( $testUser, self::$users ) ) { + $testUser = self::$users[ $testUser ]; + } elseif ( !$testUser instanceof TestUser ) { + throw new MWException( "Can not log in to undefined user $testUser" ); } $data = $this->doApiRequest( [ 'action' => 'login', - 'lgname' => self::$users[$user]->username, - 'lgpassword' => self::$users[$user]->password ] ); + 'lgname' => $testUser->getUser()->getName(), + 'lgpassword' => $testUser->getPassword() ] ); $token = $data[0]['login']['token']; @@ -177,20 +167,27 @@ abstract class ApiTestCase extends MediaWikiLangTestCase { [ 'action' => 'login', 'lgtoken' => $token, - 'lgname' => self::$users[$user]->username, - 'lgpassword' => self::$users[$user]->password, + 'lgname' => $testUser->getUser()->getName(), + 'lgpassword' => $testUser->getPassword(), ], $data[2] ); + if ( $data[0]['login']['result'] === 'Success' ) { + // DWIM + global $wgUser; + $wgUser = $testUser->getUser(); + RequestContext::getMain()->setUser( $wgUser ); + } + return $data; } - protected function getTokenList( $user, $session = null ) { + protected function getTokenList( TestUser $user, $session = null ) { $data = $this->doApiRequest( [ 'action' => 'tokens', 'type' => 'edit|delete|protect|move|block|unblock|watch' - ], $session, false, $user->user ); + ], $session, false, $user->getUser() ); if ( !array_key_exists( 'tokens', $data[0] ) ) { throw new MWException( 'Api failed to return a token list' ); diff --git a/tests/phpunit/includes/api/ApiTokensTest.php b/tests/phpunit/includes/api/ApiTokensTest.php index fbe97893d8..1f7c00b0a8 100644 --- a/tests/phpunit/includes/api/ApiTokensTest.php +++ b/tests/phpunit/includes/api/ApiTokensTest.php @@ -15,10 +15,10 @@ class ApiTokensTest extends ApiTestCase { } } - protected function runTokenTest( $user ) { + protected function runTokenTest( TestUser $user ) { $tokens = $this->getTokenList( $user ); - $rights = $user->user->getRights(); + $rights = $user->getUser()->getRights(); $this->assertArrayHasKey( 'edittoken', $tokens ); $this->assertArrayHasKey( 'movetoken', $tokens ); diff --git a/tests/phpunit/includes/api/ApiUploadTest.php b/tests/phpunit/includes/api/ApiUploadTest.php index 873917e8ec..de2b56bde3 100644 --- a/tests/phpunit/includes/api/ApiUploadTest.php +++ b/tests/phpunit/includes/api/ApiUploadTest.php @@ -27,11 +27,13 @@ class ApiUploadTest extends ApiTestCaseUpload { */ public function testLogin() { $user = self::$users['uploader']; + $userName = $user->getUser()->getName(); + $password = $user->getPassword(); $params = [ 'action' => 'login', - 'lgname' => $user->username, - 'lgpassword' => $user->password + 'lgname' => $userName, + 'lgpassword' => $password ]; list( $result, , $session ) = $this->doApiRequest( $params ); $this->assertArrayHasKey( "login", $result ); @@ -42,8 +44,8 @@ class ApiUploadTest extends ApiTestCaseUpload { $params = [ 'action' => 'login', 'lgtoken' => $token, - 'lgname' => $user->username, - 'lgpassword' => $user->password + 'lgname' => $userName, + 'lgpassword' => $password ]; list( $result, , $session ) = $this->doApiRequest( $params, $session ); $this->assertArrayHasKey( "login", $result ); diff --git a/tests/phpunit/includes/api/MockApi.php b/tests/phpunit/includes/api/MockApi.php index 9a64d0864a..d7db538273 100644 --- a/tests/phpunit/includes/api/MockApi.php +++ b/tests/phpunit/includes/api/MockApi.php @@ -1,12 +1,18 @@ warnings[] = $warning; + } + public function getAllowedParams() { return [ 'filename' => null, diff --git a/tests/phpunit/includes/api/RandomImageGenerator.php b/tests/phpunit/includes/api/RandomImageGenerator.php index 78cb7fb152..d5c17ee8ba 100644 --- a/tests/phpunit/includes/api/RandomImageGenerator.php +++ b/tests/phpunit/includes/api/RandomImageGenerator.php @@ -218,7 +218,7 @@ class RandomImageGenerator { } /** - * Given array( array('x' => 10, 'y' => 20), array( 'x' => 30, y=> 5 ) ) + * Given [ [ 'x' => 10, 'y' => 20 ], [ 'x' => 30, y=> 5 ] ] * returns "10,20 30,5" * Useful for SVG and imagemagick command line arguments * @param array $shape Array of arrays, each array containing x & y keys mapped to numeric values @@ -430,7 +430,7 @@ class RandomImageGenerator { /** * Get an array of random pairs of random words, like - * array( array( 'foo', 'bar' ), array( 'quux', 'baz' ) ); + * [ [ 'foo', 'bar' ], [ 'quux', 'baz' ] ]; * * @param int $number Number of pairs * @return array Two-element arrays diff --git a/tests/phpunit/includes/api/format/ApiFormatJsonTest.php b/tests/phpunit/includes/api/format/ApiFormatJsonTest.php index 8437228ee1..7eb2a35ecf 100644 --- a/tests/phpunit/includes/api/format/ApiFormatJsonTest.php +++ b/tests/phpunit/includes/api/format/ApiFormatJsonTest.php @@ -67,7 +67,7 @@ class ApiFormatJsonTest extends ApiFormatTestBase { [ [ 1 ], '/**/myCallback([1])', [ 'callback' => 'myCallback' ] ], // Cross-domain mangling - [ [ '< Cross-Domain-Policy >' ], '["\u003C Cross-Domain-Policy \u003E"]' ], + [ [ '< Cross-Domain-Policy >' ], '["\u003C Cross-Domain-Policy >"]' ], ] ), self::addFormatVersion( 2, [ // Basic types @@ -121,7 +121,7 @@ class ApiFormatJsonTest extends ApiFormatTestBase { [ [ 1 ], '/**/myCallback([1])', [ 'callback' => 'myCallback' ] ], // Cross-domain mangling - [ [ '< Cross-Domain-Policy >' ], '["\u003C Cross-Domain-Policy \u003E"]' ], + [ [ '< Cross-Domain-Policy >' ], '["\u003C Cross-Domain-Policy >"]' ], ] ) ); } diff --git a/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php b/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php index a6f22b1c38..38a1d68591 100644 --- a/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php +++ b/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php @@ -15,7 +15,11 @@ class ApiQueryRevisionsTest extends ApiTestCase { $pageName = 'Help:' . __METHOD__; $title = Title::newFromText( $pageName ); $page = WikiPage::factory( $title ); - $page->doEdit( 'Some text', 'inserting content' ); + + $page->doEditContent( + ContentHandler::makeContent( 'Some text', $page->getTitle() ), + 'inserting content' + ); $apiResult = $this->doApiRequest( [ 'action' => 'query', diff --git a/tests/phpunit/includes/api/query/ApiQueryTest.php b/tests/phpunit/includes/api/query/ApiQueryTest.php index 504b16afd4..8cb2327dfb 100644 --- a/tests/phpunit/includes/api/query/ApiQueryTest.php +++ b/tests/phpunit/includes/api/query/ApiQueryTest.php @@ -43,6 +43,7 @@ class ApiQueryTest extends ApiTestCase { $this->assertEquals( [ + 'fromencoded' => false, 'from' => 'Project:articleA', 'to' => $to->getPrefixedText(), ], @@ -51,6 +52,7 @@ class ApiQueryTest extends ApiTestCase { $this->assertEquals( [ + 'fromencoded' => false, 'from' => 'article_B', 'to' => 'Article B' ], diff --git a/tests/phpunit/includes/auth/AbstractAuthenticationProviderTest.php b/tests/phpunit/includes/auth/AbstractAuthenticationProviderTest.php new file mode 100644 index 0000000000..89e48f7235 --- /dev/null +++ b/tests/phpunit/includes/auth/AbstractAuthenticationProviderTest.php @@ -0,0 +1,28 @@ +getMockForAbstractClass( AbstractAuthenticationProvider::class ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $obj = $this->getMockForAbstractClass( 'Psr\Log\LoggerInterface' ); + $provider->setLogger( $obj ); + $this->assertSame( $obj, $providerPriv->logger, 'setLogger' ); + + $obj = AuthManager::singleton(); + $provider->setManager( $obj ); + $this->assertSame( $obj, $providerPriv->manager, 'setManager' ); + + $obj = $this->getMockForAbstractClass( 'Config' ); + $provider->setConfig( $obj ); + $this->assertSame( $obj, $providerPriv->config, 'setConfig' ); + + $this->assertType( 'string', $provider->getUniqueId(), 'getUniqueId' ); + } +} diff --git a/tests/phpunit/includes/auth/AbstractPasswordPrimaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/AbstractPasswordPrimaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..a57682b66e --- /dev/null +++ b/tests/phpunit/includes/auth/AbstractPasswordPrimaryAuthenticationProviderTest.php @@ -0,0 +1,227 @@ +getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + $this->assertTrue( $providerPriv->authoritative ); + + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class, + [ [ 'authoritative' => false ] ] + ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + $this->assertFalse( $providerPriv->authoritative ); + } + + public function testGetPasswordFactory() { + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $provider->setConfig( MediaWikiServices::getInstance()->getMainConfig() ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $obj = $providerPriv->getPasswordFactory(); + $this->assertInstanceOf( 'PasswordFactory', $obj ); + $this->assertSame( $obj, $providerPriv->getPasswordFactory() ); + } + + public function testGetPassword() { + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $provider->setConfig( MediaWikiServices::getInstance()->getMainConfig() ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $obj = $providerPriv->getPassword( null ); + $this->assertInstanceOf( 'Password', $obj ); + + $obj = $providerPriv->getPassword( 'invalid' ); + $this->assertInstanceOf( 'Password', $obj ); + } + + public function testGetNewPasswordExpiry() { + $config = new \HashConfig; + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $provider->setConfig( new \MultiConfig( [ + $config, + MediaWikiServices::getInstance()->getMainConfig() + ] ) ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'ResetPasswordExpiration' => [] ] ); + + $config->set( 'PasswordExpirationDays', 0 ); + $this->assertNull( $providerPriv->getNewPasswordExpiry( 'UTSysop' ) ); + + $config->set( 'PasswordExpirationDays', 5 ); + $this->assertEquals( + time() + 5 * 86400, + wfTimestamp( TS_UNIX, $providerPriv->getNewPasswordExpiry( 'UTSysop' ) ), + '', + 2 /* Fuzz */ + ); + + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + 'ResetPasswordExpiration' => [ function ( $user, &$expires ) { + $this->assertSame( 'UTSysop', $user->getName() ); + $expires = '30001231235959'; + } ] + ] ); + $this->assertEquals( '30001231235959', $providerPriv->getNewPasswordExpiry( 'UTSysop' ) ); + } + + public function testCheckPasswordValidity() { + $uppCalled = 0; + $uppStatus = \Status::newGood(); + $this->setMwGlobals( [ + 'wgPasswordPolicy' => [ + 'policies' => [ + 'default' => [ + 'Check' => true, + ], + ], + 'checks' => [ + 'Check' => function () use ( &$uppCalled, &$uppStatus ) { + $uppCalled++; + return $uppStatus; + }, + ], + ] + ] ); + + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $provider->setConfig( MediaWikiServices::getInstance()->getMainConfig() ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $this->assertEquals( $uppStatus, $providerPriv->checkPasswordValidity( 'foo', 'bar' ) ); + + $uppStatus->fatal( 'arbitrary-warning' ); + $this->assertEquals( $uppStatus, $providerPriv->checkPasswordValidity( 'foo', 'bar' ) ); + } + + public function testSetPasswordResetFlag() { + $config = new \HashConfig( [ + 'InvalidPasswordReset' => true, + ] ); + + $manager = new AuthManager( + new \FauxRequest(), + MediaWikiServices::getInstance()->getMainConfig() + ); + + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $provider->setConfig( $config ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setManager( $manager ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $manager->removeAuthenticationSessionData( null ); + $status = \Status::newGood(); + $providerPriv->setPasswordResetFlag( 'Foo', $status ); + $this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + + $manager->removeAuthenticationSessionData( null ); + $status = \Status::newGood(); + $status->error( 'testing' ); + $providerPriv->setPasswordResetFlag( 'Foo', $status ); + $ret = $manager->getAuthenticationSessionData( 'reset-pass' ); + $this->assertNotNull( $ret ); + $this->assertSame( 'resetpass-validity-soft', $ret->msg->getKey() ); + $this->assertFalse( $ret->hard ); + + $config->set( 'InvalidPasswordReset', false ); + $manager->removeAuthenticationSessionData( null ); + $providerPriv->setPasswordResetFlag( 'Foo', $status ); + $ret = $manager->getAuthenticationSessionData( 'reset-pass' ); + $this->assertNull( $ret ); + } + + public function testFailResponse() { + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class, + [ [ 'authoritative' => false ] ] + ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $req = new PasswordAuthenticationRequest; + + $ret = $providerPriv->failResponse( $req ); + $this->assertSame( AuthenticationResponse::ABSTAIN, $ret->status ); + + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class, + [ [ 'authoritative' => true ] ] + ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $req->password = ''; + $ret = $providerPriv->failResponse( $req ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'wrongpasswordempty', $ret->message->getKey() ); + + $req->password = 'X'; + $ret = $providerPriv->failResponse( $req ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'wrongpassword', $ret->message->getKey() ); + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $response + */ + public function testGetAuthenticationRequests( $action, $response ) { + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + + $this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public static function provideGetAuthenticationRequests() { + return [ + [ AuthManager::ACTION_LOGIN, [ new PasswordAuthenticationRequest() ] ], + [ AuthManager::ACTION_CREATE, [ new PasswordAuthenticationRequest() ] ], + [ AuthManager::ACTION_LINK, [] ], + [ AuthManager::ACTION_CHANGE, [ new PasswordAuthenticationRequest() ] ], + [ AuthManager::ACTION_REMOVE, [ new PasswordAuthenticationRequest() ] ], + ]; + } + + public function testProviderRevokeAccessForUser() { + $req = new PasswordAuthenticationRequest; + $req->action = AuthManager::ACTION_REMOVE; + $req->username = 'foo'; + $req->password = null; + + $provider = $this->getMockForAbstractClass( + AbstractPasswordPrimaryAuthenticationProvider::class + ); + $provider->expects( $this->once() ) + ->method( 'providerChangeAuthenticationData' ) + ->with( $this->equalTo( $req ) ); + + $provider->providerRevokeAccessForUser( 'foo' ); + } + +} diff --git a/tests/phpunit/includes/auth/AbstractPreAuthenticationProviderTest.php b/tests/phpunit/includes/auth/AbstractPreAuthenticationProviderTest.php new file mode 100644 index 0000000000..963845183d --- /dev/null +++ b/tests/phpunit/includes/auth/AbstractPreAuthenticationProviderTest.php @@ -0,0 +1,45 @@ +getMockForAbstractClass( AbstractPreAuthenticationProvider::class ); + + $this->assertEquals( + [], + $provider->getAuthenticationRequests( AuthManager::ACTION_LOGIN, [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAuthentication( [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, AuthManager::AUTOCREATE_SOURCE_SESSION ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, false ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountLink( $user ) + ); + + $res = AuthenticationResponse::newPass(); + $provider->postAuthentication( $user, $res ); + $provider->postAccountCreation( $user, $user, $res ); + $provider->postAccountLink( $user, $res ); + } +} diff --git a/tests/phpunit/includes/auth/AbstractPrimaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/AbstractPrimaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..d8588d51d1 --- /dev/null +++ b/tests/phpunit/includes/auth/AbstractPrimaryAuthenticationProviderTest.php @@ -0,0 +1,174 @@ +getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class ); + + try { + $provider->continuePrimaryAuthentication( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + } + + try { + $provider->continuePrimaryAccountCreation( $user, $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + } + + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + + $this->assertTrue( $provider->providerAllowsPropertyChange( 'foo' ) ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, AuthManager::AUTOCREATE_SOURCE_SESSION ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, false ) + ); + + $this->assertNull( + $provider->finishAccountCreation( $user, $user, AuthenticationResponse::newPass() ) + ); + $provider->autoCreatedAccount( $user, AuthManager::AUTOCREATE_SOURCE_SESSION ); + + $res = AuthenticationResponse::newPass(); + $provider->postAuthentication( $user, $res ); + $provider->postAccountCreation( $user, $user, $res ); + $provider->postAccountLink( $user, $res ); + + $provider->expects( $this->once() ) + ->method( 'testUserExists' ) + ->with( $this->equalTo( 'foo' ) ) + ->will( $this->returnValue( true ) ); + $this->assertTrue( $provider->testUserCanAuthenticate( 'foo' ) ); + } + + public function testProviderRevokeAccessForUser() { + $reqs = []; + for ( $i = 0; $i < 3; $i++ ) { + $reqs[$i] = $this->getMock( AuthenticationRequest::class ); + $reqs[$i]->done = false; + } + + $provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class ); + $provider->expects( $this->once() )->method( 'getAuthenticationRequests' ) + ->with( + $this->identicalTo( AuthManager::ACTION_REMOVE ), + $this->identicalTo( [ 'username' => 'UTSysop' ] ) + ) + ->will( $this->returnValue( $reqs ) ); + $provider->expects( $this->exactly( 3 ) )->method( 'providerChangeAuthenticationData' ) + ->will( $this->returnCallback( function ( $req ) { + $this->assertSame( 'UTSysop', $req->username ); + $this->assertFalse( $req->done ); + $req->done = true; + } ) ); + + $provider->providerRevokeAccessForUser( 'UTSysop' ); + + foreach ( $reqs as $i => $req ) { + $this->assertTrue( $req->done, "#$i" ); + } + } + + /** + * @dataProvider providePrimaryAccountLink + * @param string $type PrimaryAuthenticationProvider::TYPE_* constant + * @param string $msg Error message from beginPrimaryAccountLink + */ + public function testPrimaryAccountLink( $type, $msg ) { + $provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class ); + $provider->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( $type ) ); + + $class = AbstractPrimaryAuthenticationProvider::class; + $msg1 = "{$class}::beginPrimaryAccountLink $msg"; + $msg2 = "{$class}::continuePrimaryAccountLink is not implemented."; + + $user = \User::newFromName( 'Whatever' ); + + try { + $provider->beginPrimaryAccountLink( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( $msg1, $ex->getMessage() ); + } + try { + $provider->continuePrimaryAccountLink( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( $msg2, $ex->getMessage() ); + } + } + + public static function providePrimaryAccountLink() { + return [ + [ + PrimaryAuthenticationProvider::TYPE_NONE, + 'should not be called on a non-link provider.', + ], + [ + PrimaryAuthenticationProvider::TYPE_CREATE, + 'should not be called on a non-link provider.', + ], + [ + PrimaryAuthenticationProvider::TYPE_LINK, + 'is not implemented.', + ], + ]; + } + + /** + * @dataProvider provideProviderNormalizeUsername + */ + public function testProviderNormalizeUsername( $name, $expect ) { + // fake interwiki map for the 'Interwiki prefix' testcase + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + 'InterwikiLoadPrefix' => [ + function ( $prefix, &$iwdata ) { + if ( $prefix === 'interwiki' ) { + $iwdata = [ + 'iw_url' => 'http://example.com/', + 'iw_local' => 0, + 'iw_trans' => 0, + ]; + return false; + } + }, + ], + ] ); + + $provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class ); + $this->assertSame( $expect, $provider->providerNormalizeUsername( $name ) ); + } + + public static function provideProviderNormalizeUsername() { + return [ + 'Leading space' => [ ' Leading space', 'Leading space' ], + 'Trailing space ' => [ 'Trailing space ', 'Trailing space' ], + 'Namespace prefix' => [ 'Talk:Username', null ], + 'Interwiki prefix' => [ 'interwiki:Username', null ], + 'With hash' => [ 'name with # hash', null ], + 'Multi spaces' => [ 'Multi spaces', 'Multi spaces' ], + 'Lowercase' => [ 'lowercase', 'Lowercase' ], + 'Invalid character' => [ 'in[]valid', null ], + 'With slash' => [ 'with / slash', null ], + 'Underscores' => [ '___under__scores___', 'Under scores' ], + ]; + } + +} diff --git a/tests/phpunit/includes/auth/AbstractSecondaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/AbstractSecondaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..bb90dd9837 --- /dev/null +++ b/tests/phpunit/includes/auth/AbstractSecondaryAuthenticationProviderTest.php @@ -0,0 +1,84 @@ +getMockForAbstractClass( AbstractSecondaryAuthenticationProvider::class ); + + try { + $provider->continueSecondaryAuthentication( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + } + + try { + $provider->continueSecondaryAccountCreation( $user, $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + } + + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + + $this->assertTrue( $provider->providerAllowsPropertyChange( 'foo' ) ); + $this->assertEquals( + \StatusValue::newGood( 'ignored' ), + $provider->providerAllowsAuthenticationDataChange( $req ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, AuthManager::AUTOCREATE_SOURCE_SESSION ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, false ) + ); + + $provider->providerChangeAuthenticationData( $req ); + $provider->autoCreatedAccount( $user, AuthManager::AUTOCREATE_SOURCE_SESSION ); + + $res = AuthenticationResponse::newPass(); + $provider->postAuthentication( $user, $res ); + $provider->postAccountCreation( $user, $user, $res ); + } + + public function testProviderRevokeAccessForUser() { + $reqs = []; + for ( $i = 0; $i < 3; $i++ ) { + $reqs[$i] = $this->getMock( AuthenticationRequest::class ); + $reqs[$i]->done = false; + } + + $provider = $this->getMockBuilder( AbstractSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'providerChangeAuthenticationData' ] ) + ->getMockForAbstractClass(); + $provider->expects( $this->once() )->method( 'getAuthenticationRequests' ) + ->with( + $this->identicalTo( AuthManager::ACTION_REMOVE ), + $this->identicalTo( [ 'username' => 'UTSysop' ] ) + ) + ->will( $this->returnValue( $reqs ) ); + $provider->expects( $this->exactly( 3 ) )->method( 'providerChangeAuthenticationData' ) + ->will( $this->returnCallback( function ( $req ) { + $this->assertSame( 'UTSysop', $req->username ); + $this->assertFalse( $req->done ); + $req->done = true; + } ) ); + + $provider->providerRevokeAccessForUser( 'UTSysop' ); + + foreach ( $reqs as $i => $req ) { + $this->assertTrue( $req->done, "#$i" ); + } + } +} diff --git a/tests/phpunit/includes/auth/AuthManagerTest.php b/tests/phpunit/includes/auth/AuthManagerTest.php new file mode 100644 index 0000000000..f57db11b23 --- /dev/null +++ b/tests/phpunit/includes/auth/AuthManagerTest.php @@ -0,0 +1,3619 @@ +setMwGlobals( [ 'wgAuth' => null ] ); + $this->stashMwGlobals( [ 'wgHooks' ] ); + } + + /** + * Sets a mock on a hook + * @param string $hook + * @param object $expect From $this->once(), $this->never(), etc. + * @return object $mock->expects( $expect )->method( ... ). + */ + protected function hook( $hook, $expect ) { + global $wgHooks; + $mock = $this->getMock( __CLASS__, [ "on$hook" ] ); + $wgHooks[$hook] = [ $mock ]; + return $mock->expects( $expect )->method( "on$hook" ); + } + + /** + * Unsets a hook + * @param string $hook + */ + protected function unhook( $hook ) { + global $wgHooks; + $wgHooks[$hook] = []; + } + + /** + * Ensure a value is a clean Message object + * @param string|Message $key + * @param array $params + * @return Message + */ + protected function message( $key, $params = [] ) { + if ( $key === null ) { + return null; + } + if ( $key instanceof \MessageSpecifier ) { + $params = $key->getParams(); + $key = $key->getKey(); + } + return new \Message( $key, $params, \Language::factory( 'en' ) ); + } + + /** + * Initialize the AuthManagerConfig variable in $this->config + * + * Uses data from the various 'mocks' fields. + */ + protected function initializeConfig() { + $config = [ + 'preauth' => [ + ], + 'primaryauth' => [ + ], + 'secondaryauth' => [ + ], + ]; + + foreach ( [ 'preauth', 'primaryauth', 'secondaryauth' ] as $type ) { + $key = $type . 'Mocks'; + foreach ( $this->$key as $mock ) { + $config[$type][$mock->getUniqueId()] = [ 'factory' => function () use ( $mock ) { + return $mock; + } ]; + } + } + + $this->config->set( 'AuthManagerConfig', $config ); + $this->config->set( 'LanguageCode', 'en' ); + $this->config->set( 'NewUserLog', false ); + } + + /** + * Initialize $this->manager + * @param bool $regen Force a call to $this->initializeConfig() + */ + protected function initializeManager( $regen = false ) { + if ( $regen || !$this->config ) { + $this->config = new \HashConfig(); + } + if ( $regen || !$this->request ) { + $this->request = new \FauxRequest(); + } + if ( !$this->logger ) { + $this->logger = new \TestLogger(); + } + + if ( $regen || !$this->config->has( 'AuthManagerConfig' ) ) { + $this->initializeConfig(); + } + $this->manager = new AuthManager( $this->request, $this->config ); + $this->manager->setLogger( $this->logger ); + $this->managerPriv = \TestingAccessWrapper::newFromObject( $this->manager ); + } + + /** + * Setup SessionManager with a mock session provider + * @param bool|null $canChangeUser If non-null, canChangeUser will be mocked to return this + * @param array $methods Additional methods to mock + * @return array (MediaWiki\Session\SessionProvider, ScopedCallback) + */ + protected function getMockSessionProvider( $canChangeUser = null, array $methods = [] ) { + if ( !$this->config ) { + $this->config = new \HashConfig(); + $this->initializeConfig(); + } + $this->config->set( 'ObjectCacheSessionExpiry', 100 ); + + $methods[] = '__toString'; + $methods[] = 'describe'; + if ( $canChangeUser !== null ) { + $methods[] = 'canChangeUser'; + } + $provider = $this->getMockBuilder( 'DummySessionProvider' ) + ->setMethods( $methods ) + ->getMock(); + $provider->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockSessionProvider' ) ); + $provider->expects( $this->any() )->method( 'describe' ) + ->will( $this->returnValue( 'MockSessionProvider sessions' ) ); + if ( $canChangeUser !== null ) { + $provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( $canChangeUser ) ); + } + $this->config->set( 'SessionProviders', [ + [ 'factory' => function () use ( $provider ) { + return $provider; + } ], + ] ); + + $manager = new \MediaWiki\Session\SessionManager( [ + 'config' => $this->config, + 'logger' => new \Psr\Log\NullLogger(), + 'store' => new \HashBagOStuff(), + ] ); + \TestingAccessWrapper::newFromObject( $manager )->getProvider( (string)$provider ); + + $reset = \MediaWiki\Session\TestUtils::setSessionManagerSingleton( $manager ); + + if ( $this->request ) { + $manager->getSessionForRequest( $this->request ); + } + + return [ $provider, $reset ]; + } + + public function testSingleton() { + // Temporarily clear out the global singleton, if any, to test creating + // one. + $rProp = new \ReflectionProperty( AuthManager::class, 'instance' ); + $rProp->setAccessible( true ); + $old = $rProp->getValue(); + $cb = new ScopedCallback( [ $rProp, 'setValue' ], [ $old ] ); + $rProp->setValue( null ); + + $singleton = AuthManager::singleton(); + $this->assertInstanceOf( AuthManager::class, AuthManager::singleton() ); + $this->assertSame( $singleton, AuthManager::singleton() ); + $this->assertSame( \RequestContext::getMain()->getRequest(), $singleton->getRequest() ); + $this->assertSame( + \RequestContext::getMain()->getConfig(), + \TestingAccessWrapper::newFromObject( $singleton )->config + ); + } + + public function testCanAuthenticateNow() { + $this->initializeManager(); + + list( $provider, $reset ) = $this->getMockSessionProvider( false ); + $this->assertFalse( $this->manager->canAuthenticateNow() ); + ScopedCallback::consume( $reset ); + + list( $provider, $reset ) = $this->getMockSessionProvider( true ); + $this->assertTrue( $this->manager->canAuthenticateNow() ); + ScopedCallback::consume( $reset ); + } + + public function testNormalizeUsername() { + $mocks = [ + $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ), + $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ), + $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ), + $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ), + ]; + foreach ( $mocks as $key => $mock ) { + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( $key ) ); + } + $mocks[0]->expects( $this->once() )->method( 'providerNormalizeUsername' ) + ->with( $this->identicalTo( 'XYZ' ) ) + ->willReturn( 'Foo' ); + $mocks[1]->expects( $this->once() )->method( 'providerNormalizeUsername' ) + ->with( $this->identicalTo( 'XYZ' ) ) + ->willReturn( 'Foo' ); + $mocks[2]->expects( $this->once() )->method( 'providerNormalizeUsername' ) + ->with( $this->identicalTo( 'XYZ' ) ) + ->willReturn( null ); + $mocks[3]->expects( $this->once() )->method( 'providerNormalizeUsername' ) + ->with( $this->identicalTo( 'XYZ' ) ) + ->willReturn( 'Bar!' ); + + $this->primaryauthMocks = $mocks; + + $this->initializeManager(); + + $this->assertSame( [ 'Foo', 'Bar!' ], $this->manager->normalizeUsername( 'XYZ' ) ); + } + + /** + * @dataProvider provideSecuritySensitiveOperationStatus + * @param bool $mutableSession + */ + public function testSecuritySensitiveOperationStatus( $mutableSession ) { + $this->logger = new \Psr\Log\NullLogger(); + $user = \User::newFromName( 'UTSysop' ); + $provideUser = null; + $reauth = $mutableSession ? AuthManager::SEC_REAUTH : AuthManager::SEC_FAIL; + + list( $provider, $reset ) = $this->getMockSessionProvider( + $mutableSession, [ 'provideSessionInfo' ] + ); + $provider->expects( $this->any() )->method( 'provideSessionInfo' ) + ->will( $this->returnCallback( function () use ( $provider, &$provideUser ) { + return new SessionInfo( SessionInfo::MIN_PRIORITY, [ + 'provider' => $provider, + 'id' => \DummySessionProvider::ID, + 'persisted' => true, + 'userInfo' => UserInfo::newFromUser( $provideUser, true ) + ] ); + } ) ); + $this->initializeManager(); + + $this->config->set( 'ReauthenticateTime', [] ); + $this->config->set( 'AllowSecuritySensitiveOperationIfCannotReauthenticate', [] ); + $provideUser = new \User; + $session = $provider->getManager()->getSessionForRequest( $this->request ); + $this->assertSame( 0, $session->getUser()->getId(), 'sanity check' ); + + // Anonymous user => reauth + $session->set( 'AuthManager:lastAuthId', 0 ); + $session->set( 'AuthManager:lastAuthTimestamp', time() - 5 ); + $this->assertSame( $reauth, $this->manager->securitySensitiveOperationStatus( 'foo' ) ); + + $provideUser = $user; + $session = $provider->getManager()->getSessionForRequest( $this->request ); + $this->assertSame( $user->getId(), $session->getUser()->getId(), 'sanity check' ); + + // Error for no default (only gets thrown for non-anonymous user) + $session->set( 'AuthManager:lastAuthId', $user->getId() + 1 ); + $session->set( 'AuthManager:lastAuthTimestamp', time() - 5 ); + try { + $this->manager->securitySensitiveOperationStatus( 'foo' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + $mutableSession + ? '$wgReauthenticateTime lacks a default' + : '$wgAllowSecuritySensitiveOperationIfCannotReauthenticate lacks a default', + $ex->getMessage() + ); + } + + if ( $mutableSession ) { + $this->config->set( 'ReauthenticateTime', [ + 'test' => 100, + 'test2' => -1, + 'default' => 10, + ] ); + + // Mismatched user ID + $session->set( 'AuthManager:lastAuthId', $user->getId() + 1 ); + $session->set( 'AuthManager:lastAuthTimestamp', time() - 5 ); + $this->assertSame( + AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'foo' ) + ); + $this->assertSame( + AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'test' ) + ); + $this->assertSame( + AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'test2' ) + ); + + // Missing time + $session->set( 'AuthManager:lastAuthId', $user->getId() ); + $session->set( 'AuthManager:lastAuthTimestamp', null ); + $this->assertSame( + AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'foo' ) + ); + $this->assertSame( + AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'test' ) + ); + $this->assertSame( + AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'test2' ) + ); + + // Recent enough to pass + $session->set( 'AuthManager:lastAuthTimestamp', time() - 5 ); + $this->assertSame( + AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'foo' ) + ); + + // Not recent enough to pass + $session->set( 'AuthManager:lastAuthTimestamp', time() - 20 ); + $this->assertSame( + AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'foo' ) + ); + // But recent enough for the 'test' operation + $this->assertSame( + AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'test' ) + ); + } else { + $this->config->set( 'AllowSecuritySensitiveOperationIfCannotReauthenticate', [ + 'test' => false, + 'default' => true, + ] ); + + $this->assertEquals( + AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'foo' ) + ); + + $this->assertEquals( + AuthManager::SEC_FAIL, $this->manager->securitySensitiveOperationStatus( 'test' ) + ); + } + + // Test hook, all three possible values + foreach ( [ + AuthManager::SEC_OK => AuthManager::SEC_OK, + AuthManager::SEC_REAUTH => $reauth, + AuthManager::SEC_FAIL => AuthManager::SEC_FAIL, + ] as $hook => $expect ) { + $this->hook( 'SecuritySensitiveOperationStatus', $this->exactly( 2 ) ) + ->with( + $this->anything(), + $this->anything(), + $this->callback( function ( $s ) use ( $session ) { + return $s->getId() === $session->getId(); + } ), + $mutableSession ? $this->equalTo( 500, 1 ) : $this->equalTo( -1 ) + ) + ->will( $this->returnCallback( function ( &$v ) use ( $hook ) { + $v = $hook; + return true; + } ) ); + $session->set( 'AuthManager:lastAuthTimestamp', time() - 500 ); + $this->assertEquals( + $expect, $this->manager->securitySensitiveOperationStatus( 'test' ), "hook $hook" + ); + $this->assertEquals( + $expect, $this->manager->securitySensitiveOperationStatus( 'test2' ), "hook $hook" + ); + $this->unhook( 'SecuritySensitiveOperationStatus' ); + } + + ScopedCallback::consume( $reset ); + } + + public function onSecuritySensitiveOperationStatus( &$status, $operation, $session, $time ) { + } + + public static function provideSecuritySensitiveOperationStatus() { + return [ + [ true ], + [ false ], + ]; + } + + /** + * @dataProvider provideUserCanAuthenticate + * @param bool $primary1Can + * @param bool $primary2Can + * @param bool $expect + */ + public function testUserCanAuthenticate( $primary1Can, $primary2Can, $expect ) { + $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock1->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary1' ) ); + $mock1->expects( $this->any() )->method( 'testUserCanAuthenticate' ) + ->with( $this->equalTo( 'UTSysop' ) ) + ->will( $this->returnValue( $primary1Can ) ); + $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock2->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary2' ) ); + $mock2->expects( $this->any() )->method( 'testUserCanAuthenticate' ) + ->with( $this->equalTo( 'UTSysop' ) ) + ->will( $this->returnValue( $primary2Can ) ); + $this->primaryauthMocks = [ $mock1, $mock2 ]; + + $this->initializeManager( true ); + $this->assertSame( $expect, $this->manager->userCanAuthenticate( 'UTSysop' ) ); + } + + public static function provideUserCanAuthenticate() { + return [ + [ false, false, false ], + [ true, false, true ], + [ false, true, true ], + [ true, true, true ], + ]; + } + + public function testRevokeAccessForUser() { + $this->initializeManager(); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary' ) ); + $mock->expects( $this->once() )->method( 'providerRevokeAccessForUser' ) + ->with( $this->equalTo( 'UTSysop' ) ); + $this->primaryauthMocks = [ $mock ]; + + $this->initializeManager( true ); + $this->logger->setCollect( true ); + + $this->manager->revokeAccessForUser( 'UTSysop' ); + + $this->assertSame( [ + [ LogLevel::INFO, 'Revoking access for {user}' ], + ], $this->logger->getBuffer() ); + } + + public function testProviderCreation() { + $mocks = [ + 'pre' => $this->getMockForAbstractClass( PreAuthenticationProvider::class ), + 'primary' => $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ), + 'secondary' => $this->getMockForAbstractClass( SecondaryAuthenticationProvider::class ), + ]; + foreach ( $mocks as $key => $mock ) { + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( $key ) ); + $mock->expects( $this->once() )->method( 'setLogger' ); + $mock->expects( $this->once() )->method( 'setManager' ); + $mock->expects( $this->once() )->method( 'setConfig' ); + } + $this->preauthMocks = [ $mocks['pre'] ]; + $this->primaryauthMocks = [ $mocks['primary'] ]; + $this->secondaryauthMocks = [ $mocks['secondary'] ]; + + // Normal operation + $this->initializeManager(); + $this->assertSame( + $mocks['primary'], + $this->managerPriv->getAuthenticationProvider( 'primary' ) + ); + $this->assertSame( + $mocks['secondary'], + $this->managerPriv->getAuthenticationProvider( 'secondary' ) + ); + $this->assertSame( + $mocks['pre'], + $this->managerPriv->getAuthenticationProvider( 'pre' ) + ); + $this->assertSame( + [ 'pre' => $mocks['pre'] ], + $this->managerPriv->getPreAuthenticationProviders() + ); + $this->assertSame( + [ 'primary' => $mocks['primary'] ], + $this->managerPriv->getPrimaryAuthenticationProviders() + ); + $this->assertSame( + [ 'secondary' => $mocks['secondary'] ], + $this->managerPriv->getSecondaryAuthenticationProviders() + ); + + // Duplicate IDs + $mock1 = $this->getMockForAbstractClass( PreAuthenticationProvider::class ); + $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock1->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $this->preauthMocks = [ $mock1 ]; + $this->primaryauthMocks = [ $mock2 ]; + $this->secondaryauthMocks = []; + $this->initializeManager( true ); + try { + $this->managerPriv->getAuthenticationProvider( 'Y' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \RuntimeException $ex ) { + $class1 = get_class( $mock1 ); + $class2 = get_class( $mock2 ); + $this->assertSame( + "Duplicate specifications for id X (classes $class1 and $class2)", $ex->getMessage() + ); + } + + // Wrong classes + $mock = $this->getMockForAbstractClass( AuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $class = get_class( $mock ); + $this->preauthMocks = [ $mock ]; + $this->primaryauthMocks = [ $mock ]; + $this->secondaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + try { + $this->managerPriv->getPreAuthenticationProviders(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \RuntimeException $ex ) { + $this->assertSame( + "Expected instance of MediaWiki\\Auth\\PreAuthenticationProvider, got $class", + $ex->getMessage() + ); + } + try { + $this->managerPriv->getPrimaryAuthenticationProviders(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \RuntimeException $ex ) { + $this->assertSame( + "Expected instance of MediaWiki\\Auth\\PrimaryAuthenticationProvider, got $class", + $ex->getMessage() + ); + } + try { + $this->managerPriv->getSecondaryAuthenticationProviders(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \RuntimeException $ex ) { + $this->assertSame( + "Expected instance of MediaWiki\\Auth\\SecondaryAuthenticationProvider, got $class", + $ex->getMessage() + ); + } + + // Sorting + $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock3 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock1->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'A' ) ); + $mock2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'B' ) ); + $mock3->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'C' ) ); + $this->preauthMocks = []; + $this->primaryauthMocks = [ $mock1, $mock2, $mock3 ]; + $this->secondaryauthMocks = []; + $this->initializeConfig(); + $config = $this->config->get( 'AuthManagerConfig' ); + + $this->initializeManager( false ); + $this->assertSame( + [ 'A' => $mock1, 'B' => $mock2, 'C' => $mock3 ], + $this->managerPriv->getPrimaryAuthenticationProviders(), + 'sanity check' + ); + + $config['primaryauth']['A']['sort'] = 100; + $config['primaryauth']['C']['sort'] = -1; + $this->config->set( 'AuthManagerConfig', $config ); + $this->initializeManager( false ); + $this->assertSame( + [ 'C' => $mock3, 'B' => $mock2, 'A' => $mock1 ], + $this->managerPriv->getPrimaryAuthenticationProviders() + ); + } + + public function testSetDefaultUserOptions() { + $this->initializeManager(); + + $context = \RequestContext::getMain(); + $reset = new ScopedCallback( [ $context, 'setLanguage' ], [ $context->getLanguage() ] ); + $context->setLanguage( 'de' ); + $this->setMwGlobals( 'wgContLang', \Language::factory( 'zh' ) ); + + $user = \User::newFromName( self::usernameForCreation() ); + $user->addToDatabase(); + $oldToken = $user->getToken(); + $this->managerPriv->setDefaultUserOptions( $user, false ); + $user->saveSettings(); + $this->assertNotEquals( $oldToken, $user->getToken() ); + $this->assertSame( 'zh', $user->getOption( 'language' ) ); + $this->assertSame( 'zh', $user->getOption( 'variant' ) ); + + $user = \User::newFromName( self::usernameForCreation() ); + $user->addToDatabase(); + $oldToken = $user->getToken(); + $this->managerPriv->setDefaultUserOptions( $user, true ); + $user->saveSettings(); + $this->assertNotEquals( $oldToken, $user->getToken() ); + $this->assertSame( 'de', $user->getOption( 'language' ) ); + $this->assertSame( 'zh', $user->getOption( 'variant' ) ); + + $this->setMwGlobals( 'wgContLang', \Language::factory( 'en' ) ); + + $user = \User::newFromName( self::usernameForCreation() ); + $user->addToDatabase(); + $oldToken = $user->getToken(); + $this->managerPriv->setDefaultUserOptions( $user, true ); + $user->saveSettings(); + $this->assertNotEquals( $oldToken, $user->getToken() ); + $this->assertSame( 'de', $user->getOption( 'language' ) ); + $this->assertSame( null, $user->getOption( 'variant' ) ); + } + + public function testForcePrimaryAuthenticationProviders() { + $mockA = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mockB = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mockB2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mockA->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'A' ) ); + $mockB->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'B' ) ); + $mockB2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'B' ) ); + $this->primaryauthMocks = [ $mockA ]; + + $this->logger = new \TestLogger( true ); + + // Test without first initializing the configured providers + $this->initializeManager(); + $this->manager->forcePrimaryAuthenticationProviders( [ $mockB ], 'testing' ); + $this->assertSame( + [ 'B' => $mockB ], $this->managerPriv->getPrimaryAuthenticationProviders() + ); + $this->assertSame( null, $this->managerPriv->getAuthenticationProvider( 'A' ) ); + $this->assertSame( $mockB, $this->managerPriv->getAuthenticationProvider( 'B' ) ); + $this->assertSame( [ + [ LogLevel::WARNING, 'Overriding AuthManager primary authn because testing' ], + ], $this->logger->getBuffer() ); + $this->logger->clearBuffer(); + + // Test with first initializing the configured providers + $this->initializeManager(); + $this->assertSame( $mockA, $this->managerPriv->getAuthenticationProvider( 'A' ) ); + $this->assertSame( null, $this->managerPriv->getAuthenticationProvider( 'B' ) ); + $this->request->getSession()->setSecret( 'AuthManager::authnState', 'test' ); + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', 'test' ); + $this->manager->forcePrimaryAuthenticationProviders( [ $mockB ], 'testing' ); + $this->assertSame( + [ 'B' => $mockB ], $this->managerPriv->getPrimaryAuthenticationProviders() + ); + $this->assertSame( null, $this->managerPriv->getAuthenticationProvider( 'A' ) ); + $this->assertSame( $mockB, $this->managerPriv->getAuthenticationProvider( 'B' ) ); + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::authnState' ) ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + $this->assertSame( [ + [ LogLevel::WARNING, 'Overriding AuthManager primary authn because testing' ], + [ + LogLevel::WARNING, + 'PrimaryAuthenticationProviders have already been accessed! I hope nothing breaks.' + ], + ], $this->logger->getBuffer() ); + $this->logger->clearBuffer(); + + // Test duplicate IDs + $this->initializeManager(); + try { + $this->manager->forcePrimaryAuthenticationProviders( [ $mockB, $mockB2 ], 'testing' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \RuntimeException $ex ) { + $class1 = get_class( $mockB ); + $class2 = get_class( $mockB2 ); + $this->assertSame( + "Duplicate specifications for id B (classes $class2 and $class1)", $ex->getMessage() + ); + } + + // Wrong classes + $mock = $this->getMockForAbstractClass( AuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $class = get_class( $mock ); + try { + $this->manager->forcePrimaryAuthenticationProviders( [ $mock ], 'testing' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \RuntimeException $ex ) { + $this->assertSame( + "Expected instance of MediaWiki\\Auth\\PrimaryAuthenticationProvider, got $class", + $ex->getMessage() + ); + } + } + + public function testBeginAuthentication() { + $this->initializeManager(); + + // Immutable session + list( $provider, $reset ) = $this->getMockSessionProvider( false ); + $this->hook( 'UserLoggedIn', $this->never() ); + $this->request->getSession()->setSecret( 'AuthManager::authnState', 'test' ); + try { + $this->manager->beginAuthentication( [], 'http://localhost/' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \LogicException $ex ) { + $this->assertSame( 'Authentication is not possible now', $ex->getMessage() ); + } + $this->unhook( 'UserLoggedIn' ); + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::authnState' ) ); + ScopedCallback::consume( $reset ); + $this->initializeManager( true ); + + // CreatedAccountAuthenticationRequest + $user = \User::newFromName( 'UTSysop' ); + $reqs = [ + new CreatedAccountAuthenticationRequest( $user->getId(), $user->getName() ) + ]; + $this->hook( 'UserLoggedIn', $this->never() ); + try { + $this->manager->beginAuthentication( $reqs, 'http://localhost/' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \LogicException $ex ) { + $this->assertSame( + 'CreatedAccountAuthenticationRequests are only valid on the same AuthManager ' . + 'that created the account', + $ex->getMessage() + ); + } + $this->unhook( 'UserLoggedIn' ); + + $this->request->getSession()->clear(); + $this->request->getSession()->setSecret( 'AuthManager::authnState', 'test' ); + $this->managerPriv->createdAccountAuthenticationRequests = [ $reqs[0] ]; + $this->hook( 'UserLoggedIn', $this->once() ) + ->with( $this->callback( function ( $u ) use ( $user ) { + return $user->getId() === $u->getId() && $user->getName() === $u->getName(); + } ) ); + $this->hook( 'AuthManagerLoginAuthenticateAudit', $this->once() ); + $this->logger->setCollect( true ); + $ret = $this->manager->beginAuthentication( $reqs, 'http://localhost/' ); + $this->logger->setCollect( false ); + $this->unhook( 'UserLoggedIn' ); + $this->unhook( 'AuthManagerLoginAuthenticateAudit' ); + $this->assertSame( AuthenticationResponse::PASS, $ret->status ); + $this->assertSame( $user->getName(), $ret->username ); + $this->assertSame( $user->getId(), $this->request->getSessionData( 'AuthManager:lastAuthId' ) ); + $this->assertEquals( + time(), $this->request->getSessionData( 'AuthManager:lastAuthTimestamp' ), + 'timestamp ±1', 1 + ); + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::authnState' ) ); + $this->assertSame( $user->getId(), $this->request->getSession()->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::INFO, 'Logging in {user} after account creation' ], + ], $this->logger->getBuffer() ); + } + + public function testCreateFromLogin() { + $user = \User::newFromName( 'UTSysop' ); + $req1 = $this->getMock( AuthenticationRequest::class ); + $req2 = $this->getMock( AuthenticationRequest::class ); + $req3 = $this->getMock( AuthenticationRequest::class ); + $userReq = new UsernameAuthenticationRequest; + $userReq->username = 'UTDummy'; + + $req1->returnToUrl = 'http://localhost/'; + $req2->returnToUrl = 'http://localhost/'; + $req3->returnToUrl = 'http://localhost/'; + $req3->username = 'UTDummy'; + $userReq->returnToUrl = 'http://localhost/'; + + // Passing one into beginAuthentication(), and an immediate FAIL + $primary = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class ); + $this->primaryauthMocks = [ $primary ]; + $this->initializeManager( true ); + $res = AuthenticationResponse::newFail( wfMessage( 'foo' ) ); + $res->createRequest = $req1; + $primary->expects( $this->any() )->method( 'beginPrimaryAuthentication' ) + ->will( $this->returnValue( $res ) ); + $createReq = new CreateFromLoginAuthenticationRequest( + null, [ $req2->getUniqueId() => $req2 ] + ); + $this->logger->setCollect( true ); + $ret = $this->manager->beginAuthentication( [ $createReq ], 'http://localhost/' ); + $this->logger->setCollect( false ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertInstanceOf( CreateFromLoginAuthenticationRequest::class, $ret->createRequest ); + $this->assertSame( $req1, $ret->createRequest->createRequest ); + $this->assertEquals( [ $req2->getUniqueId() => $req2 ], $ret->createRequest->maybeLink ); + + // UI, then FAIL in beginAuthentication() + $primary = $this->getMockBuilder( AbstractPrimaryAuthenticationProvider::class ) + ->setMethods( [ 'continuePrimaryAuthentication' ] ) + ->getMockForAbstractClass(); + $this->primaryauthMocks = [ $primary ]; + $this->initializeManager( true ); + $primary->expects( $this->any() )->method( 'beginPrimaryAuthentication' ) + ->will( $this->returnValue( + AuthenticationResponse::newUI( [ $req1 ], wfMessage( 'foo' ) ) + ) ); + $res = AuthenticationResponse::newFail( wfMessage( 'foo' ) ); + $res->createRequest = $req2; + $primary->expects( $this->any() )->method( 'continuePrimaryAuthentication' ) + ->will( $this->returnValue( $res ) ); + $this->logger->setCollect( true ); + $ret = $this->manager->beginAuthentication( [], 'http://localhost/' ); + $this->assertSame( AuthenticationResponse::UI, $ret->status, 'sanity check' ); + $ret = $this->manager->continueAuthentication( [] ); + $this->logger->setCollect( false ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertInstanceOf( CreateFromLoginAuthenticationRequest::class, $ret->createRequest ); + $this->assertSame( $req2, $ret->createRequest->createRequest ); + $this->assertEquals( [], $ret->createRequest->maybeLink ); + + // Pass into beginAccountCreation(), see that maybeLink and createRequest get copied + $primary = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class ); + $this->primaryauthMocks = [ $primary ]; + $this->initializeManager( true ); + $createReq = new CreateFromLoginAuthenticationRequest( $req3, [ $req2 ] ); + $createReq->returnToUrl = 'http://localhost/'; + $createReq->username = 'UTDummy'; + $res = AuthenticationResponse::newUI( [ $req1 ], wfMessage( 'foo' ) ); + $primary->expects( $this->any() )->method( 'beginPrimaryAccountCreation' ) + ->with( $this->anything(), $this->anything(), [ $userReq, $createReq, $req3 ] ) + ->will( $this->returnValue( $res ) ); + $primary->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $this->logger->setCollect( true ); + $ret = $this->manager->beginAccountCreation( + $user, [ $userReq, $createReq ], 'http://localhost/' + ); + $this->logger->setCollect( false ); + $this->assertSame( AuthenticationResponse::UI, $ret->status ); + $state = $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ); + $this->assertNotNull( $state ); + $this->assertEquals( [ $userReq, $createReq, $req3 ], $state['reqs'] ); + $this->assertEquals( [ $req2 ], $state['maybeLink'] ); + } + + /** + * @dataProvider provideAuthentication + * @param StatusValue $preResponse + * @param array $primaryResponses + * @param array $secondaryResponses + * @param array $managerResponses + * @param bool $link Whether the primary authentication provider is a "link" provider + */ + public function testAuthentication( + StatusValue $preResponse, array $primaryResponses, array $secondaryResponses, + array $managerResponses, $link = false + ) { + $this->initializeManager(); + $user = \User::newFromName( 'UTSysop' ); + $id = $user->getId(); + $name = $user->getName(); + + // Set up lots of mocks... + $req = new RememberMeAuthenticationRequest; + $req->rememberMe = (bool)rand( 0, 1 ); + $req->pre = $preResponse; + $req->primary = $primaryResponses; + $req->secondary = $secondaryResponses; + $mocks = []; + foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) { + $class = ucfirst( $key ) . 'AuthenticationProvider'; + $mocks[$key] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + $mocks[$key . '2'] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key . '2']->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key . '2' ) ); + $mocks[$key . '3'] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key . '3']->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key . '3' ) ); + } + foreach ( $mocks as $mock ) { + $mock->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnValue( [] ) ); + } + + $mocks['pre']->expects( $this->once() )->method( 'testForAuthentication' ) + ->will( $this->returnCallback( function ( $reqs ) use ( $req ) { + $this->assertContains( $req, $reqs ); + return $req->pre; + } ) ); + + $ct = count( $req->primary ); + $callback = $this->returnCallback( function ( $reqs ) use ( $req ) { + $this->assertContains( $req, $reqs ); + return array_shift( $req->primary ); + } ); + $mocks['primary']->expects( $this->exactly( min( 1, $ct ) ) ) + ->method( 'beginPrimaryAuthentication' ) + ->will( $callback ); + $mocks['primary']->expects( $this->exactly( max( 0, $ct - 1 ) ) ) + ->method( 'continuePrimaryAuthentication' ) + ->will( $callback ); + if ( $link ) { + $mocks['primary']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + } + + $ct = count( $req->secondary ); + $callback = $this->returnCallback( function ( $user, $reqs ) use ( $id, $name, $req ) { + $this->assertSame( $id, $user->getId() ); + $this->assertSame( $name, $user->getName() ); + $this->assertContains( $req, $reqs ); + return array_shift( $req->secondary ); + } ); + $mocks['secondary']->expects( $this->exactly( min( 1, $ct ) ) ) + ->method( 'beginSecondaryAuthentication' ) + ->will( $callback ); + $mocks['secondary']->expects( $this->exactly( max( 0, $ct - 1 ) ) ) + ->method( 'continueSecondaryAuthentication' ) + ->will( $callback ); + + $abstain = AuthenticationResponse::newAbstain(); + $mocks['pre2']->expects( $this->atMost( 1 ) )->method( 'testForAuthentication' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $mocks['primary2']->expects( $this->atMost( 1 ) )->method( 'beginPrimaryAuthentication' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['primary2']->expects( $this->never() )->method( 'continuePrimaryAuthentication' ); + $mocks['secondary2']->expects( $this->atMost( 1 ) )->method( 'beginSecondaryAuthentication' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['secondary2']->expects( $this->never() )->method( 'continueSecondaryAuthentication' ); + $mocks['secondary3']->expects( $this->atMost( 1 ) )->method( 'beginSecondaryAuthentication' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['secondary3']->expects( $this->never() )->method( 'continueSecondaryAuthentication' ); + + $this->preauthMocks = [ $mocks['pre'], $mocks['pre2'] ]; + $this->primaryauthMocks = [ $mocks['primary'], $mocks['primary2'] ]; + $this->secondaryauthMocks = [ + $mocks['secondary3'], $mocks['secondary'], $mocks['secondary2'], + // So linking happens + new ConfirmLinkSecondaryAuthenticationProvider, + ]; + $this->initializeManager( true ); + $this->logger->setCollect( true ); + + $constraint = \PHPUnit_Framework_Assert::logicalOr( + $this->equalTo( AuthenticationResponse::PASS ), + $this->equalTo( AuthenticationResponse::FAIL ) + ); + $providers = array_filter( + array_merge( + $this->preauthMocks, $this->primaryauthMocks, $this->secondaryauthMocks + ), + function ( $p ) { + return is_callable( [ $p, 'expects' ] ); + } + ); + foreach ( $providers as $p ) { + $p->postCalled = false; + $p->expects( $this->atMost( 1 ) )->method( 'postAuthentication' ) + ->willReturnCallback( function ( $user, $response ) use ( $constraint, $p ) { + if ( $user !== null ) { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( 'UTSysop', $user->getName() ); + } + $this->assertInstanceOf( AuthenticationResponse::class, $response ); + $this->assertThat( $response->status, $constraint ); + $p->postCalled = $response->status; + } ); + } + + $session = $this->request->getSession(); + $session->setRememberUser( !$req->rememberMe ); + + foreach ( $managerResponses as $i => $response ) { + $success = $response instanceof AuthenticationResponse && + $response->status === AuthenticationResponse::PASS; + if ( $success ) { + $this->hook( 'UserLoggedIn', $this->once() ) + ->with( $this->callback( function ( $user ) use ( $id, $name ) { + return $user->getId() === $id && $user->getName() === $name; + } ) ); + } else { + $this->hook( 'UserLoggedIn', $this->never() ); + } + if ( $success || ( + $response instanceof AuthenticationResponse && + $response->status === AuthenticationResponse::FAIL && + $response->message->getKey() !== 'authmanager-authn-not-in-progress' && + $response->message->getKey() !== 'authmanager-authn-no-primary' + ) + ) { + $this->hook( 'AuthManagerLoginAuthenticateAudit', $this->once() ); + } else { + $this->hook( 'AuthManagerLoginAuthenticateAudit', $this->never() ); + } + + $ex = null; + try { + if ( !$i ) { + $ret = $this->manager->beginAuthentication( [ $req ], 'http://localhost/' ); + } else { + $ret = $this->manager->continueAuthentication( [ $req ] ); + } + if ( $response instanceof \Exception ) { + $this->fail( 'Expected exception not thrown', "Response $i" ); + } + } catch ( \Exception $ex ) { + if ( !$response instanceof \Exception ) { + throw $ex; + } + $this->assertEquals( $response->getMessage(), $ex->getMessage(), "Response $i, exception" ); + $this->assertNull( $session->getSecret( 'AuthManager::authnState' ), + "Response $i, exception, session state" ); + $this->unhook( 'UserLoggedIn' ); + $this->unhook( 'AuthManagerLoginAuthenticateAudit' ); + return; + } + + $this->unhook( 'UserLoggedIn' ); + $this->unhook( 'AuthManagerLoginAuthenticateAudit' ); + + $this->assertSame( 'http://localhost/', $req->returnToUrl ); + + $ret->message = $this->message( $ret->message ); + $this->assertEquals( $response, $ret, "Response $i, response" ); + if ( $success ) { + $this->assertSame( $id, $session->getUser()->getId(), + "Response $i, authn" ); + } else { + $this->assertSame( 0, $session->getUser()->getId(), + "Response $i, authn" ); + } + if ( $success || $response->status === AuthenticationResponse::FAIL ) { + $this->assertNull( $session->getSecret( 'AuthManager::authnState' ), + "Response $i, session state" ); + foreach ( $providers as $p ) { + $this->assertSame( $response->status, $p->postCalled, + "Response $i, post-auth callback called" ); + } + } else { + $this->assertNotNull( $session->getSecret( 'AuthManager::authnState' ), + "Response $i, session state" ); + foreach ( $ret->neededRequests as $neededReq ) { + $this->assertEquals( AuthManager::ACTION_LOGIN, $neededReq->action, + "Response $i, neededRequest action" ); + } + $this->assertEquals( + $ret->neededRequests, + $this->manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN_CONTINUE ), + "Response $i, continuation check" + ); + foreach ( $providers as $p ) { + $this->assertFalse( $p->postCalled, "Response $i, post-auth callback not called" ); + } + } + + $state = $session->getSecret( 'AuthManager::authnState' ); + $maybeLink = isset( $state['maybeLink'] ) ? $state['maybeLink'] : []; + if ( $link && $response->status === AuthenticationResponse::RESTART ) { + $this->assertEquals( + $response->createRequest->maybeLink, + $maybeLink, + "Response $i, maybeLink" + ); + } else { + $this->assertEquals( [], $maybeLink, "Response $i, maybeLink" ); + } + } + + if ( $success ) { + $this->assertSame( $req->rememberMe, $session->shouldRememberUser(), + 'rememberMe checkbox had effect' ); + } else { + $this->assertNotSame( $req->rememberMe, $session->shouldRememberUser(), + 'rememberMe checkbox wasn\'t applied' ); + } + } + + public function provideAuthentication() { + $user = \User::newFromName( 'UTSysop' ); + $id = $user->getId(); + $name = $user->getName(); + + $rememberReq = new RememberMeAuthenticationRequest; + $rememberReq->action = AuthManager::ACTION_LOGIN; + + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $req->foobar = 'baz'; + $restartResponse = AuthenticationResponse::newRestart( + $this->message( 'authmanager-authn-no-local-user' ) + ); + $restartResponse->neededRequests = [ $rememberReq ]; + + $restartResponse2Pass = AuthenticationResponse::newPass( null ); + $restartResponse2Pass->linkRequest = $req; + $restartResponse2 = AuthenticationResponse::newRestart( + $this->message( 'authmanager-authn-no-local-user-link' ) + ); + $restartResponse2->createRequest = new CreateFromLoginAuthenticationRequest( + null, [ $req->getUniqueId() => $req ] + ); + $restartResponse2->createRequest->action = AuthManager::ACTION_LOGIN; + $restartResponse2->neededRequests = [ $rememberReq, $restartResponse2->createRequest ]; + + return [ + 'Failure in pre-auth' => [ + StatusValue::newFatal( 'fail-from-pre' ), + [], + [], + [ + AuthenticationResponse::newFail( $this->message( 'fail-from-pre' ) ), + AuthenticationResponse::newFail( + $this->message( 'authmanager-authn-not-in-progress' ) + ), + ] + ], + 'Failure in primary' => [ + StatusValue::newGood(), + $tmp = [ + AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ), + ], + [], + $tmp + ], + 'All primary abstain' => [ + StatusValue::newGood(), + [ + AuthenticationResponse::newAbstain(), + ], + [], + [ + AuthenticationResponse::newFail( $this->message( 'authmanager-authn-no-primary' ) ) + ] + ], + 'Primary UI, then redirect, then fail' => [ + StatusValue::newGood(), + $tmp = [ + AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newRedirect( [ $req ], '/foo.html', [ 'foo' => 'bar' ] ), + AuthenticationResponse::newFail( $this->message( 'fail-in-primary-continue' ) ), + ], + [], + $tmp + ], + 'Primary redirect, then abstain' => [ + StatusValue::newGood(), + [ + $tmp = AuthenticationResponse::newRedirect( + [ $req ], '/foo.html', [ 'foo' => 'bar' ] + ), + AuthenticationResponse::newAbstain(), + ], + [], + [ + $tmp, + new \DomainException( + 'MockPrimaryAuthenticationProvider::continuePrimaryAuthentication() returned ABSTAIN' + ) + ] + ], + 'Primary UI, then pass with no local user' => [ + StatusValue::newGood(), + [ + $tmp = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newPass( null ), + ], + [], + [ + $tmp, + $restartResponse, + ] + ], + 'Primary UI, then pass with no local user (link type)' => [ + StatusValue::newGood(), + [ + $tmp = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + $restartResponse2Pass, + ], + [], + [ + $tmp, + $restartResponse2, + ], + true + ], + 'Primary pass with invalid username' => [ + StatusValue::newGood(), + [ + AuthenticationResponse::newPass( '<>' ), + ], + [], + [ + new \DomainException( 'MockPrimaryAuthenticationProvider returned an invalid username: <>' ), + ] + ], + 'Secondary fail' => [ + StatusValue::newGood(), + [ + AuthenticationResponse::newPass( $name ), + ], + $tmp = [ + AuthenticationResponse::newFail( $this->message( 'fail-in-secondary' ) ), + ], + $tmp + ], + 'Secondary UI, then abstain' => [ + StatusValue::newGood(), + [ + AuthenticationResponse::newPass( $name ), + ], + [ + $tmp = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newAbstain() + ], + [ + $tmp, + AuthenticationResponse::newPass( $name ), + ] + ], + 'Secondary pass' => [ + StatusValue::newGood(), + [ + AuthenticationResponse::newPass( $name ), + ], + [ + AuthenticationResponse::newPass() + ], + [ + AuthenticationResponse::newPass( $name ), + ] + ], + ]; + } + + /** + * @dataProvider provideUserExists + * @param bool $primary1Exists + * @param bool $primary2Exists + * @param bool $expect + */ + public function testUserExists( $primary1Exists, $primary2Exists, $expect ) { + $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock1->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary1' ) ); + $mock1->expects( $this->any() )->method( 'testUserExists' ) + ->with( $this->equalTo( 'UTSysop' ) ) + ->will( $this->returnValue( $primary1Exists ) ); + $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock2->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary2' ) ); + $mock2->expects( $this->any() )->method( 'testUserExists' ) + ->with( $this->equalTo( 'UTSysop' ) ) + ->will( $this->returnValue( $primary2Exists ) ); + $this->primaryauthMocks = [ $mock1, $mock2 ]; + + $this->initializeManager( true ); + $this->assertSame( $expect, $this->manager->userExists( 'UTSysop' ) ); + } + + public static function provideUserExists() { + return [ + [ false, false, false ], + [ true, false, true ], + [ false, true, true ], + [ true, true, true ], + ]; + } + + /** + * @dataProvider provideAllowsAuthenticationDataChange + * @param StatusValue $primaryReturn + * @param StatusValue $secondaryReturn + * @param Status $expect + */ + public function testAllowsAuthenticationDataChange( $primaryReturn, $secondaryReturn, $expect ) { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + + $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock1->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( '1' ) ); + $mock1->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' ) + ->with( $this->equalTo( $req ) ) + ->will( $this->returnValue( $primaryReturn ) ); + $mock2 = $this->getMockForAbstractClass( SecondaryAuthenticationProvider::class ); + $mock2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( '2' ) ); + $mock2->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' ) + ->with( $this->equalTo( $req ) ) + ->will( $this->returnValue( $secondaryReturn ) ); + + $this->primaryauthMocks = [ $mock1 ]; + $this->secondaryauthMocks = [ $mock2 ]; + $this->initializeManager( true ); + $this->assertEquals( $expect, $this->manager->allowsAuthenticationDataChange( $req ) ); + } + + public static function provideAllowsAuthenticationDataChange() { + $ignored = \Status::newGood( 'ignored' ); + $ignored->warning( 'authmanager-change-not-supported' ); + + $okFromPrimary = StatusValue::newGood(); + $okFromPrimary->warning( 'warning-from-primary' ); + $okFromSecondary = StatusValue::newGood(); + $okFromSecondary->warning( 'warning-from-secondary' ); + + return [ + [ + StatusValue::newGood(), + StatusValue::newGood(), + \Status::newGood(), + ], + [ + StatusValue::newGood(), + StatusValue::newGood( 'ignore' ), + \Status::newGood(), + ], + [ + StatusValue::newGood( 'ignored' ), + StatusValue::newGood(), + \Status::newGood(), + ], + [ + StatusValue::newGood( 'ignored' ), + StatusValue::newGood( 'ignored' ), + $ignored, + ], + [ + StatusValue::newFatal( 'fail from primary' ), + StatusValue::newGood(), + \Status::newFatal( 'fail from primary' ), + ], + [ + $okFromPrimary, + StatusValue::newGood(), + \Status::wrap( $okFromPrimary ), + ], + [ + StatusValue::newGood(), + StatusValue::newFatal( 'fail from secondary' ), + \Status::newFatal( 'fail from secondary' ), + ], + [ + StatusValue::newGood(), + $okFromSecondary, + \Status::wrap( $okFromSecondary ), + ], + ]; + } + + public function testChangeAuthenticationData() { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $req->username = 'UTSysop'; + + $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock1->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( '1' ) ); + $mock1->expects( $this->once() )->method( 'providerChangeAuthenticationData' ) + ->with( $this->equalTo( $req ) ); + $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( '2' ) ); + $mock2->expects( $this->once() )->method( 'providerChangeAuthenticationData' ) + ->with( $this->equalTo( $req ) ); + + $this->primaryauthMocks = [ $mock1, $mock2 ]; + $this->initializeManager( true ); + $this->logger->setCollect( true ); + $this->manager->changeAuthenticationData( $req ); + $this->assertSame( [ + [ LogLevel::INFO, 'Changing authentication data for {user} class {what}' ], + ], $this->logger->getBuffer() ); + } + + public function testCanCreateAccounts() { + $types = [ + PrimaryAuthenticationProvider::TYPE_CREATE => true, + PrimaryAuthenticationProvider::TYPE_LINK => true, + PrimaryAuthenticationProvider::TYPE_NONE => false, + ]; + + foreach ( $types as $type => $can ) { + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( $type ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( $type ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + $this->assertSame( $can, $this->manager->canCreateAccounts(), $type ); + } + } + + public function testCheckAccountCreatePermissions() { + global $wgGroupPermissions; + + $this->stashMwGlobals( [ 'wgGroupPermissions' ] ); + + $this->initializeManager( true ); + + $wgGroupPermissions['*']['createaccount'] = true; + $this->assertEquals( + \Status::newGood(), + $this->manager->checkAccountCreatePermissions( new \User ) + ); + + $this->setMwGlobals( [ 'wgReadOnly' => 'Because' ] ); + $this->assertEquals( + \Status::newFatal( 'readonlytext', 'Because' ), + $this->manager->checkAccountCreatePermissions( new \User ) + ); + $this->setMwGlobals( [ 'wgReadOnly' => false ] ); + + $wgGroupPermissions['*']['createaccount'] = false; + $status = $this->manager->checkAccountCreatePermissions( new \User ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'badaccess-groups' ) ); + $wgGroupPermissions['*']['createaccount'] = true; + + $user = \User::newFromName( 'UTBlockee' ); + if ( $user->getID() == 0 ) { + $user->addToDatabase(); + \TestUser::setPasswordForUser( $user, 'UTBlockeePassword' ); + $user->saveSettings(); + } + $oldBlock = \Block::newFromTarget( 'UTBlockee' ); + if ( $oldBlock ) { + // An old block will prevent our new one from saving. + $oldBlock->delete(); + } + $blockOptions = [ + 'address' => 'UTBlockee', + 'user' => $user->getID(), + 'reason' => __METHOD__, + 'expiry' => time() + 100500, + 'createAccount' => true, + ]; + $block = new \Block( $blockOptions ); + $block->insert(); + $status = $this->manager->checkAccountCreatePermissions( $user ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'cantcreateaccount-text' ) ); + + $blockOptions = [ + 'address' => '127.0.0.0/24', + 'reason' => __METHOD__, + 'expiry' => time() + 100500, + 'createAccount' => true, + ]; + $block = new \Block( $blockOptions ); + $block->insert(); + $scopeVariable = new ScopedCallback( [ $block, 'delete' ] ); + $status = $this->manager->checkAccountCreatePermissions( new \User ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'cantcreateaccount-range-text' ) ); + ScopedCallback::consume( $scopeVariable ); + + $this->setMwGlobals( [ + 'wgEnableDnsBlacklist' => true, + 'wgDnsBlacklistUrls' => [ + 'local.wmftest.net', // This will resolve for every subdomain, which works to test "listed?" + ], + 'wgProxyWhitelist' => [], + ] ); + $status = $this->manager->checkAccountCreatePermissions( new \User ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'sorbs_create_account_reason' ) ); + $this->setMwGlobals( 'wgProxyWhitelist', [ '127.0.0.1' ] ); + $status = $this->manager->checkAccountCreatePermissions( new \User ); + $this->assertTrue( $status->isGood() ); + } + + /** + * @param string $uniq + * @return string + */ + private static function usernameForCreation( $uniq = '' ) { + $i = 0; + do { + $username = "UTAuthManagerTestAccountCreation" . $uniq . ++$i; + } while ( \User::newFromName( $username )->getId() !== 0 ); + return $username; + } + + public function testCanCreateAccount() { + $username = self::usernameForCreation(); + $this->initializeManager(); + + $this->assertEquals( + \Status::newFatal( 'authmanager-create-disabled' ), + $this->manager->canCreateAccount( $username ) + ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( true ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->assertEquals( + \Status::newFatal( 'userexists' ), + $this->manager->canCreateAccount( $username ) + ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->assertEquals( + \Status::newFatal( 'noname' ), + $this->manager->canCreateAccount( $username . '<>' ) + ); + + $this->assertEquals( + \Status::newFatal( 'userexists' ), + $this->manager->canCreateAccount( 'UTSysop' ) + ); + + $this->assertEquals( + \Status::newGood(), + $this->manager->canCreateAccount( $username ) + ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newFatal( 'fail' ) ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->assertEquals( + \Status::newFatal( 'fail' ), + $this->manager->canCreateAccount( $username ) + ); + } + + public function testBeginAccountCreation() { + $creator = \User::newFromName( 'UTSysop' ); + $userReq = new UsernameAuthenticationRequest; + $this->logger = new \TestLogger( false, function ( $message, $level ) { + return $level === LogLevel::DEBUG ? null : $message; + } ); + $this->initializeManager(); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', 'test' ); + $this->hook( 'LocalUserCreated', $this->never() ); + try { + $this->manager->beginAccountCreation( + $creator, [], 'http://localhost/' + ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \LogicException $ex ) { + $this->assertEquals( 'Account creation is not possible', $ex->getMessage() ); + } + $this->unhook( 'LocalUserCreated' ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( true ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->beginAccountCreation( $creator, [], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'noname', $ret->message->getKey() ); + + $this->hook( 'LocalUserCreated', $this->never() ); + $userReq->username = self::usernameForCreation(); + $userReq2 = new UsernameAuthenticationRequest; + $userReq2->username = $userReq->username . 'X'; + $ret = $this->manager->beginAccountCreation( + $creator, [ $userReq, $userReq2 ], 'http://localhost/' + ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'noname', $ret->message->getKey() ); + + $this->setMwGlobals( [ 'wgReadOnly' => 'Because' ] ); + $this->hook( 'LocalUserCreated', $this->never() ); + $userReq->username = self::usernameForCreation(); + $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'readonlytext', $ret->message->getKey() ); + $this->assertSame( [ 'Because' ], $ret->message->getParams() ); + $this->setMwGlobals( [ 'wgReadOnly' => false ] ); + + $this->hook( 'LocalUserCreated', $this->never() ); + $userReq->username = self::usernameForCreation(); + $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'userexists', $ret->message->getKey() ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newFatal( 'fail' ) ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->hook( 'LocalUserCreated', $this->never() ); + $userReq->username = self::usernameForCreation(); + $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'fail', $ret->message->getKey() ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->hook( 'LocalUserCreated', $this->never() ); + $userReq->username = self::usernameForCreation() . '<>'; + $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'noname', $ret->message->getKey() ); + + $this->hook( 'LocalUserCreated', $this->never() ); + $userReq->username = $creator->getName(); + $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'userexists', $ret->message->getKey() ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $mock->expects( $this->any() )->method( 'testForAccountCreation' ) + ->will( $this->returnValue( StatusValue::newFatal( 'fail' ) ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $req = $this->getMockBuilder( UserDataAuthenticationRequest::class ) + ->setMethods( [ 'populateUser' ] ) + ->getMock(); + $req->expects( $this->any() )->method( 'populateUser' ) + ->willReturn( \StatusValue::newFatal( 'populatefail' ) ); + $userReq->username = self::usernameForCreation(); + $ret = $this->manager->beginAccountCreation( + $creator, [ $userReq, $req ], 'http://localhost/' + ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'populatefail', $ret->message->getKey() ); + + $req = new UserDataAuthenticationRequest; + $userReq->username = self::usernameForCreation(); + + $ret = $this->manager->beginAccountCreation( + $creator, [ $userReq, $req ], 'http://localhost/' + ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'fail', $ret->message->getKey() ); + + $this->manager->beginAccountCreation( + \User::newFromName( $userReq->username ), [ $userReq, $req ], 'http://localhost/' + ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'fail', $ret->message->getKey() ); + } + + public function testContinueAccountCreation() { + $creator = \User::newFromName( 'UTSysop' ); + $username = self::usernameForCreation(); + $this->logger = new \TestLogger( false, function ( $message, $level ) { + return $level === LogLevel::DEBUG ? null : $message; + } ); + $this->initializeManager(); + + $session = [ + 'userid' => 0, + 'username' => $username, + 'creatorid' => 0, + 'creatorname' => $username, + 'reqs' => [], + 'primary' => null, + 'primaryResponse' => null, + 'secondary' => [], + 'ranPreTests' => true, + ]; + + $this->hook( 'LocalUserCreated', $this->never() ); + try { + $this->manager->continueAccountCreation( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \LogicException $ex ) { + $this->assertEquals( 'Account creation is not possible', $ex->getMessage() ); + } + $this->unhook( 'LocalUserCreated' ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'beginPrimaryAccountCreation' )->will( + $this->returnValue( AuthenticationResponse::newFail( $this->message( 'fail' ) ) ) + ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', null ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->continueAccountCreation( [] ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'authmanager-create-not-in-progress', $ret->message->getKey() ); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', + [ 'username' => "$username<>" ] + $session ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->continueAccountCreation( [] ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'noname', $ret->message->getKey() ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', $session ); + $this->hook( 'LocalUserCreated', $this->never() ); + $cache = \ObjectCache::getLocalClusterInstance(); + $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $username ) ) ); + $ret = $this->manager->continueAccountCreation( [] ); + unset( $lock ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'usernameinprogress', $ret->message->getKey() ); + // This error shouldn't remove the existing session, because the + // raced-with process "owns" it. + $this->assertSame( + $session, $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', + [ 'username' => $creator->getName() ] + $session ); + $this->setMwGlobals( [ 'wgReadOnly' => 'Because' ] ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->continueAccountCreation( [] ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'readonlytext', $ret->message->getKey() ); + $this->assertSame( [ 'Because' ], $ret->message->getParams() ); + $this->setMwGlobals( [ 'wgReadOnly' => false ] ); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', + [ 'username' => $creator->getName() ] + $session ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->continueAccountCreation( [] ); + $this->unhook( 'LocalUserCreated' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'userexists', $ret->message->getKey() ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', + [ 'userid' => $creator->getId() ] + $session ); + $this->hook( 'LocalUserCreated', $this->never() ); + try { + $ret = $this->manager->continueAccountCreation( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertEquals( "User \"{$username}\" should exist now, but doesn't!", $ex->getMessage() ); + } + $this->unhook( 'LocalUserCreated' ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + + $id = $creator->getId(); + $name = $creator->getName(); + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', + [ 'username' => $name, 'userid' => $id + 1 ] + $session ); + $this->hook( 'LocalUserCreated', $this->never() ); + try { + $ret = $this->manager->continueAccountCreation( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertEquals( + "User \"{$name}\" exists, but ID $id != " . ( $id + 1 ) . '!', $ex->getMessage() + ); + } + $this->unhook( 'LocalUserCreated' ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + + $req = $this->getMockBuilder( UserDataAuthenticationRequest::class ) + ->setMethods( [ 'populateUser' ] ) + ->getMock(); + $req->expects( $this->any() )->method( 'populateUser' ) + ->willReturn( \StatusValue::newFatal( 'populatefail' ) ); + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', + [ 'reqs' => [ $req ] ] + $session ); + $ret = $this->manager->continueAccountCreation( [] ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'populatefail', $ret->message->getKey() ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ) + ); + } + + /** + * @dataProvider provideAccountCreation + * @param StatusValue $preTest + * @param StatusValue $primaryTest + * @param StatusValue $secondaryTest + * @param array $primaryResponses + * @param array $secondaryResponses + * @param array $managerResponses + */ + public function testAccountCreation( + StatusValue $preTest, $primaryTest, $secondaryTest, + array $primaryResponses, array $secondaryResponses, array $managerResponses + ) { + $creator = \User::newFromName( 'UTSysop' ); + $username = self::usernameForCreation(); + + $this->initializeManager(); + + // Set up lots of mocks... + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $req->preTest = $preTest; + $req->primaryTest = $primaryTest; + $req->secondaryTest = $secondaryTest; + $req->primary = $primaryResponses; + $req->secondary = $secondaryResponses; + $mocks = []; + foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) { + $class = ucfirst( $key ) . 'AuthenticationProvider'; + $mocks[$key] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + $mocks[$key]->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $mocks[$key]->expects( $this->any() )->method( 'testForAccountCreation' ) + ->will( $this->returnCallback( + function ( $user, $creatorIn, $reqs ) + use ( $username, $creator, $req, $key ) + { + $this->assertSame( $username, $user->getName() ); + $this->assertSame( $creator->getId(), $creatorIn->getId() ); + $this->assertSame( $creator->getName(), $creatorIn->getName() ); + $foundReq = false; + foreach ( $reqs as $r ) { + $this->assertSame( $username, $r->username ); + $foundReq = $foundReq || get_class( $r ) === get_class( $req ); + } + $this->assertTrue( $foundReq, '$reqs contains $req' ); + $k = $key . 'Test'; + return $req->$k; + } + ) ); + + for ( $i = 2; $i <= 3; $i++ ) { + $mocks[$key . $i] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key . $i]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key . $i ) ); + $mocks[$key . $i]->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $mocks[$key . $i]->expects( $this->atMost( 1 ) )->method( 'testForAccountCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + } + } + + $mocks['primary']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mocks['primary']->expects( $this->any() )->method( 'testUserExists' ) + ->will( $this->returnValue( false ) ); + $ct = count( $req->primary ); + $callback = $this->returnCallback( function ( $user, $creator, $reqs ) use ( $username, $req ) { + $this->assertSame( $username, $user->getName() ); + $this->assertSame( 'UTSysop', $creator->getName() ); + $foundReq = false; + foreach ( $reqs as $r ) { + $this->assertSame( $username, $r->username ); + $foundReq = $foundReq || get_class( $r ) === get_class( $req ); + } + $this->assertTrue( $foundReq, '$reqs contains $req' ); + return array_shift( $req->primary ); + } ); + $mocks['primary']->expects( $this->exactly( min( 1, $ct ) ) ) + ->method( 'beginPrimaryAccountCreation' ) + ->will( $callback ); + $mocks['primary']->expects( $this->exactly( max( 0, $ct - 1 ) ) ) + ->method( 'continuePrimaryAccountCreation' ) + ->will( $callback ); + + $ct = count( $req->secondary ); + $callback = $this->returnCallback( function ( $user, $creator, $reqs ) use ( $username, $req ) { + $this->assertSame( $username, $user->getName() ); + $this->assertSame( 'UTSysop', $creator->getName() ); + $foundReq = false; + foreach ( $reqs as $r ) { + $this->assertSame( $username, $r->username ); + $foundReq = $foundReq || get_class( $r ) === get_class( $req ); + } + $this->assertTrue( $foundReq, '$reqs contains $req' ); + return array_shift( $req->secondary ); + } ); + $mocks['secondary']->expects( $this->exactly( min( 1, $ct ) ) ) + ->method( 'beginSecondaryAccountCreation' ) + ->will( $callback ); + $mocks['secondary']->expects( $this->exactly( max( 0, $ct - 1 ) ) ) + ->method( 'continueSecondaryAccountCreation' ) + ->will( $callback ); + + $abstain = AuthenticationResponse::newAbstain(); + $mocks['primary2']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + $mocks['primary2']->expects( $this->any() )->method( 'testUserExists' ) + ->will( $this->returnValue( false ) ); + $mocks['primary2']->expects( $this->atMost( 1 ) )->method( 'beginPrimaryAccountCreation' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['primary2']->expects( $this->never() )->method( 'continuePrimaryAccountCreation' ); + $mocks['primary3']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_NONE ) ); + $mocks['primary3']->expects( $this->any() )->method( 'testUserExists' ) + ->will( $this->returnValue( false ) ); + $mocks['primary3']->expects( $this->never() )->method( 'beginPrimaryAccountCreation' ); + $mocks['primary3']->expects( $this->never() )->method( 'continuePrimaryAccountCreation' ); + $mocks['secondary2']->expects( $this->atMost( 1 ) ) + ->method( 'beginSecondaryAccountCreation' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['secondary2']->expects( $this->never() )->method( 'continueSecondaryAccountCreation' ); + $mocks['secondary3']->expects( $this->atMost( 1 ) ) + ->method( 'beginSecondaryAccountCreation' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['secondary3']->expects( $this->never() )->method( 'continueSecondaryAccountCreation' ); + + $this->preauthMocks = [ $mocks['pre'], $mocks['pre2'] ]; + $this->primaryauthMocks = [ $mocks['primary3'], $mocks['primary'], $mocks['primary2'] ]; + $this->secondaryauthMocks = [ + $mocks['secondary3'], $mocks['secondary'], $mocks['secondary2'] + ]; + + $this->logger = new \TestLogger( true, function ( $message, $level ) { + return $level === LogLevel::DEBUG ? null : $message; + } ); + $expectLog = []; + $this->initializeManager( true ); + + $constraint = \PHPUnit_Framework_Assert::logicalOr( + $this->equalTo( AuthenticationResponse::PASS ), + $this->equalTo( AuthenticationResponse::FAIL ) + ); + $providers = array_merge( + $this->preauthMocks, $this->primaryauthMocks, $this->secondaryauthMocks + ); + foreach ( $providers as $p ) { + $p->postCalled = false; + $p->expects( $this->atMost( 1 ) )->method( 'postAccountCreation' ) + ->willReturnCallback( function ( $user, $creator, $response ) + use ( $constraint, $p, $username ) + { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( $username, $user->getName() ); + $this->assertSame( 'UTSysop', $creator->getName() ); + $this->assertInstanceOf( AuthenticationResponse::class, $response ); + $this->assertThat( $response->status, $constraint ); + $p->postCalled = $response->status; + } ); + } + + // We're testing with $wgNewUserLog = false, so assert that it worked + $dbw = wfGetDB( DB_MASTER ); + $maxLogId = $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] ); + + $first = true; + $created = false; + foreach ( $managerResponses as $i => $response ) { + $success = $response instanceof AuthenticationResponse && + $response->status === AuthenticationResponse::PASS; + if ( $i === 'created' ) { + $created = true; + $this->hook( 'LocalUserCreated', $this->once() ) + ->with( + $this->callback( function ( $user ) use ( $username ) { + return $user->getName() === $username; + } ), + $this->equalTo( false ) + ); + $expectLog[] = [ LogLevel::INFO, "Creating user {user} during account creation" ]; + } else { + $this->hook( 'LocalUserCreated', $this->never() ); + } + + $ex = null; + try { + if ( $first ) { + $userReq = new UsernameAuthenticationRequest; + $userReq->username = $username; + $ret = $this->manager->beginAccountCreation( + $creator, [ $userReq, $req ], 'http://localhost/' + ); + } else { + $ret = $this->manager->continueAccountCreation( [ $req ] ); + } + if ( $response instanceof \Exception ) { + $this->fail( 'Expected exception not thrown', "Response $i" ); + } + } catch ( \Exception $ex ) { + if ( !$response instanceof \Exception ) { + throw $ex; + } + $this->assertEquals( $response->getMessage(), $ex->getMessage(), "Response $i, exception" ); + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ), + "Response $i, exception, session state" + ); + $this->unhook( 'LocalUserCreated' ); + return; + } + + $this->unhook( 'LocalUserCreated' ); + + $this->assertSame( 'http://localhost/', $req->returnToUrl ); + + if ( $success ) { + $this->assertNotNull( $ret->loginRequest, "Response $i, login marker" ); + $this->assertContains( + $ret->loginRequest, $this->managerPriv->createdAccountAuthenticationRequests, + "Response $i, login marker" + ); + + $expectLog[] = [ + LogLevel::INFO, + "MediaWiki\Auth\AuthManager::continueAccountCreation: Account creation succeeded for {user}" + ]; + + // Set some fields in the expected $response that we couldn't + // know in provideAccountCreation(). + $response->username = $username; + $response->loginRequest = $ret->loginRequest; + } else { + $this->assertNull( $ret->loginRequest, "Response $i, login marker" ); + $this->assertSame( [], $this->managerPriv->createdAccountAuthenticationRequests, + "Response $i, login marker" ); + } + $ret->message = $this->message( $ret->message ); + $this->assertEquals( $response, $ret, "Response $i, response" ); + if ( $success || $response->status === AuthenticationResponse::FAIL ) { + $this->assertNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ), + "Response $i, session state" + ); + foreach ( $providers as $p ) { + $this->assertSame( $response->status, $p->postCalled, + "Response $i, post-auth callback called" ); + } + } else { + $this->assertNotNull( + $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ), + "Response $i, session state" + ); + foreach ( $ret->neededRequests as $neededReq ) { + $this->assertEquals( AuthManager::ACTION_CREATE, $neededReq->action, + "Response $i, neededRequest action" ); + } + $this->assertEquals( + $ret->neededRequests, + $this->manager->getAuthenticationRequests( AuthManager::ACTION_CREATE_CONTINUE ), + "Response $i, continuation check" + ); + foreach ( $providers as $p ) { + $this->assertFalse( $p->postCalled, "Response $i, post-auth callback not called" ); + } + } + + if ( $created ) { + $this->assertNotEquals( 0, \User::idFromName( $username ) ); + } else { + $this->assertEquals( 0, \User::idFromName( $username ) ); + } + + $first = false; + } + + $this->assertSame( $expectLog, $this->logger->getBuffer() ); + + $this->assertSame( + $maxLogId, + $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] ) + ); + } + + public function provideAccountCreation() { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $good = StatusValue::newGood(); + + return [ + 'Pre-creation test fail in pre' => [ + StatusValue::newFatal( 'fail-from-pre' ), $good, $good, + [], + [], + [ + AuthenticationResponse::newFail( $this->message( 'fail-from-pre' ) ), + ] + ], + 'Pre-creation test fail in primary' => [ + $good, StatusValue::newFatal( 'fail-from-primary' ), $good, + [], + [], + [ + AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ), + ] + ], + 'Pre-creation test fail in secondary' => [ + $good, $good, StatusValue::newFatal( 'fail-from-secondary' ), + [], + [], + [ + AuthenticationResponse::newFail( $this->message( 'fail-from-secondary' ) ), + ] + ], + 'Failure in primary' => [ + $good, $good, $good, + $tmp = [ + AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ), + ], + [], + $tmp + ], + 'All primary abstain' => [ + $good, $good, $good, + [ + AuthenticationResponse::newAbstain(), + ], + [], + [ + AuthenticationResponse::newFail( $this->message( 'authmanager-create-no-primary' ) ) + ] + ], + 'Primary UI, then redirect, then fail' => [ + $good, $good, $good, + $tmp = [ + AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newRedirect( [ $req ], '/foo.html', [ 'foo' => 'bar' ] ), + AuthenticationResponse::newFail( $this->message( 'fail-in-primary-continue' ) ), + ], + [], + $tmp + ], + 'Primary redirect, then abstain' => [ + $good, $good, $good, + [ + $tmp = AuthenticationResponse::newRedirect( + [ $req ], '/foo.html', [ 'foo' => 'bar' ] + ), + AuthenticationResponse::newAbstain(), + ], + [], + [ + $tmp, + new \DomainException( + 'MockPrimaryAuthenticationProvider::continuePrimaryAccountCreation() returned ABSTAIN' + ) + ] + ], + 'Primary UI, then pass; secondary abstain' => [ + $good, $good, $good, + [ + $tmp1 = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newPass(), + ], + [ + AuthenticationResponse::newAbstain(), + ], + [ + $tmp1, + 'created' => AuthenticationResponse::newPass( '' ), + ] + ], + 'Primary pass; secondary UI then pass' => [ + $good, $good, $good, + [ + AuthenticationResponse::newPass( '' ), + ], + [ + $tmp1 = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newPass( '' ), + ], + [ + 'created' => $tmp1, + AuthenticationResponse::newPass( '' ), + ] + ], + 'Primary pass; secondary fail' => [ + $good, $good, $good, + [ + AuthenticationResponse::newPass(), + ], + [ + AuthenticationResponse::newFail( $this->message( '...' ) ), + ], + [ + 'created' => new \DomainException( + 'MockSecondaryAuthenticationProvider::beginSecondaryAccountCreation() returned FAIL. ' . + 'Secondary providers are not allowed to fail account creation, ' . + 'that should have been done via testForAccountCreation().' + ) + ] + ], + ]; + } + + /** + * @dataProvider provideAccountCreationLogging + * @param bool $isAnon + * @param string|null $logSubtype + */ + public function testAccountCreationLogging( $isAnon, $logSubtype ) { + $creator = $isAnon ? new \User : \User::newFromName( 'UTSysop' ); + $username = self::usernameForCreation(); + + $this->initializeManager(); + + // Set up lots of mocks... + $mock = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\PrimaryAuthenticationProvider", [] + ); + $mock->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary' ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $mock->expects( $this->any() )->method( 'testForAccountCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' ) + ->will( $this->returnValue( false ) ); + $mock->expects( $this->any() )->method( 'beginPrimaryAccountCreation' ) + ->will( $this->returnValue( AuthenticationResponse::newPass( $username ) ) ); + $mock->expects( $this->any() )->method( 'finishAccountCreation' ) + ->will( $this->returnValue( $logSubtype ) ); + + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + $this->logger->setCollect( true ); + + $this->config->set( 'NewUserLog', true ); + + $dbw = wfGetDB( DB_MASTER ); + $maxLogId = $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] ); + + $userReq = new UsernameAuthenticationRequest; + $userReq->username = $username; + $reasonReq = new CreationReasonAuthenticationRequest; + $reasonReq->reason = $this->toString(); + $ret = $this->manager->beginAccountCreation( + $creator, [ $userReq, $reasonReq ], 'http://localhost/' + ); + + $this->assertSame( AuthenticationResponse::PASS, $ret->status ); + + $user = \User::newFromName( $username ); + $this->assertNotEquals( 0, $user->getId(), 'sanity check' ); + $this->assertNotEquals( $creator->getId(), $user->getId(), 'sanity check' ); + + $data = \DatabaseLogEntry::getSelectQueryData(); + $rows = iterator_to_array( $dbw->select( + $data['tables'], + $data['fields'], + [ + 'log_id > ' . (int)$maxLogId, + 'log_type' => 'newusers' + ] + $data['conds'], + __METHOD__, + $data['options'], + $data['join_conds'] + ) ); + $this->assertCount( 1, $rows ); + $entry = \DatabaseLogEntry::newFromRow( reset( $rows ) ); + + $this->assertSame( $logSubtype ?: ( $isAnon ? 'create' : 'create2' ), $entry->getSubtype() ); + $this->assertSame( + $isAnon ? $user->getId() : $creator->getId(), + $entry->getPerformer()->getId() + ); + $this->assertSame( + $isAnon ? $user->getName() : $creator->getName(), + $entry->getPerformer()->getName() + ); + $this->assertSame( $user->getUserPage()->getFullText(), $entry->getTarget()->getFullText() ); + $this->assertSame( [ '4::userid' => $user->getId() ], $entry->getParameters() ); + $this->assertSame( $this->toString(), $entry->getComment() ); + } + + public static function provideAccountCreationLogging() { + return [ + [ true, null ], + [ true, 'foobar' ], + [ false, null ], + [ false, 'byemail' ], + ]; + } + + public function testAutoAccountCreation() { + global $wgGroupPermissions, $wgHooks; + + // PHPUnit seems to have a bug where it will call the ->with() + // callbacks for our hooks again after the test is run (WTF?), which + // breaks here because $username no longer matches $user by the end of + // the testing. + $workaroundPHPUnitBug = false; + + $username = self::usernameForCreation(); + $this->initializeManager(); + + $this->stashMwGlobals( [ 'wgGroupPermissions' ] ); + $wgGroupPermissions['*']['createaccount'] = true; + $wgGroupPermissions['*']['autocreateaccount'] = false; + + \ObjectCache::$instances[__METHOD__] = new \HashBagOStuff(); + $this->setMwGlobals( [ 'wgMainCacheType' => __METHOD__ ] ); + + // Set up lots of mocks... + $mocks = []; + foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) { + $class = ucfirst( $key ) . 'AuthenticationProvider'; + $mocks[$key] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + } + + $good = StatusValue::newGood(); + $callback = $this->callback( function ( $user ) use ( &$username, &$workaroundPHPUnitBug ) { + return $workaroundPHPUnitBug || $user->getName() === $username; + } ); + + $mocks['pre']->expects( $this->exactly( 12 ) )->method( 'testUserForCreation' ) + ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) ) + ->will( $this->onConsecutiveCalls( + StatusValue::newFatal( 'ok' ), StatusValue::newFatal( 'ok' ), // For testing permissions + StatusValue::newFatal( 'fail-in-pre' ), $good, $good, + $good, // backoff test + $good, // addToDatabase fails test + $good, // addToDatabase throws test + $good, // addToDatabase exists test + $good, $good, $good // success + ) ); + + $mocks['primary']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mocks['primary']->expects( $this->any() )->method( 'testUserExists' ) + ->will( $this->returnValue( true ) ); + $mocks['primary']->expects( $this->exactly( 9 ) )->method( 'testUserForCreation' ) + ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) ) + ->will( $this->onConsecutiveCalls( + StatusValue::newFatal( 'fail-in-primary' ), $good, + $good, // backoff test + $good, // addToDatabase fails test + $good, // addToDatabase throws test + $good, // addToDatabase exists test + $good, $good, $good + ) ); + $mocks['primary']->expects( $this->exactly( 3 ) )->method( 'autoCreatedAccount' ) + ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) ); + + $mocks['secondary']->expects( $this->exactly( 8 ) )->method( 'testUserForCreation' ) + ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) ) + ->will( $this->onConsecutiveCalls( + StatusValue::newFatal( 'fail-in-secondary' ), + $good, // backoff test + $good, // addToDatabase fails test + $good, // addToDatabase throws test + $good, // addToDatabase exists test + $good, $good, $good + ) ); + $mocks['secondary']->expects( $this->exactly( 3 ) )->method( 'autoCreatedAccount' ) + ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) ); + + $this->preauthMocks = [ $mocks['pre'] ]; + $this->primaryauthMocks = [ $mocks['primary'] ]; + $this->secondaryauthMocks = [ $mocks['secondary'] ]; + $this->initializeManager( true ); + $session = $this->request->getSession(); + + $logger = new \TestLogger( true, function ( $m ) { + $m = str_replace( 'MediaWiki\\Auth\\AuthManager::autoCreateUser: ', '', $m ); + return $m; + } ); + $this->manager->setLogger( $logger ); + + try { + $user = \User::newFromName( 'UTSysop' ); + $this->manager->autoCreateUser( $user, 'InvalidSource', true ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Unknown auto-creation source: InvalidSource', $ex->getMessage() ); + } + + // First, check an existing user + $session->clear(); + $user = \User::newFromName( 'UTSysop' ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $expect = \Status::newGood(); + $expect->warning( 'userexists' ); + $this->assertEquals( $expect, $ret ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertSame( 'UTSysop', $user->getName() ); + $this->assertEquals( $user->getId(), $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, '{username} already exists locally' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + $session->clear(); + $user = \User::newFromName( 'UTSysop' ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, false ); + $this->unhook( 'LocalUserCreated' ); + $expect = \Status::newGood(); + $expect->warning( 'userexists' ); + $this->assertEquals( $expect, $ret ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertSame( 'UTSysop', $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, '{username} already exists locally' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + // Wiki is read-only + $session->clear(); + $this->setMwGlobals( [ 'wgReadOnly' => 'Because' ] ); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'readonlytext', 'Because' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'denied by wfReadOnly(): {reason}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->setMwGlobals( [ 'wgReadOnly' => false ] ); + + // Session blacklisted + $session->clear(); + $session->set( 'AuthManager::AutoCreateBlacklist', 'test' ); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'test' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'blacklisted in session {sessionid}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + $session->clear(); + $session->set( 'AuthManager::AutoCreateBlacklist', StatusValue::newFatal( 'test2' ) ); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'test2' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'blacklisted in session {sessionid}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + // Uncreatable name + $session->clear(); + $user = \User::newFromName( $username . '@' ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'noname' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username . '@', $user->getId() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'name "{username}" is not creatable' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( 'noname', $session->get( 'AuthManager::AutoCreateBlacklist' ) ); + + // IP unable to create accounts + $wgGroupPermissions['*']['createaccount'] = false; + $wgGroupPermissions['*']['autocreateaccount'] = false; + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'authmanager-autocreate-noperm' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'IP lacks the ability to create or autocreate accounts' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( + 'authmanager-autocreate-noperm', $session->get( 'AuthManager::AutoCreateBlacklist' ) + ); + + // Test that both permutations of permissions are allowed + // (this hits the two "ok" entries in $mocks['pre']) + $wgGroupPermissions['*']['createaccount'] = false; + $wgGroupPermissions['*']['autocreateaccount'] = true; + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'ok' ), $ret ); + + $wgGroupPermissions['*']['createaccount'] = true; + $wgGroupPermissions['*']['autocreateaccount'] = false; + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'ok' ), $ret ); + $logger->clearBuffer(); + + // Test lock fail + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $cache = \ObjectCache::getLocalClusterInstance(); + $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $username ) ) ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + unset( $lock ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'usernameinprogress' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'Could not acquire account creation lock' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test pre-authentication provider fail + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'fail-in-pre' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'Provider denied creation of {username}: {reason}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertEquals( + StatusValue::newFatal( 'fail-in-pre' ), $session->get( 'AuthManager::AutoCreateBlacklist' ) + ); + + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'fail-in-primary' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'Provider denied creation of {username}: {reason}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertEquals( + StatusValue::newFatal( 'fail-in-primary' ), $session->get( 'AuthManager::AutoCreateBlacklist' ) + ); + + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'fail-in-secondary' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'Provider denied creation of {username}: {reason}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertEquals( + StatusValue::newFatal( 'fail-in-secondary' ), $session->get( 'AuthManager::AutoCreateBlacklist' ) + ); + + // Test backoff + $cache = \ObjectCache::getLocalClusterInstance(); + $backoffKey = wfMemcKey( 'AuthManager', 'autocreate-failed', md5( $username ) ); + $cache->set( $backoffKey, true ); + $session->clear(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newFatal( 'authmanager-autocreate-exception' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::DEBUG, '{username} denied by prior creation attempt failures' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( null, $session->get( 'AuthManager::AutoCreateBlacklist' ) ); + $cache->delete( $backoffKey ); + + // Test addToDatabase fails + $session->clear(); + $user = $this->getMock( 'User', [ 'addToDatabase' ] ); + $user->expects( $this->once() )->method( 'addToDatabase' ) + ->will( $this->returnValue( \Status::newFatal( 'because' ) ) ); + $user->setName( $username ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->assertEquals( \Status::newFatal( 'because' ), $ret ); + $this->assertEquals( 0, $user->getId() ); + $this->assertNotEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ], + [ LogLevel::ERROR, '{username} failed with message {msg}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( null, $session->get( 'AuthManager::AutoCreateBlacklist' ) ); + + // Test addToDatabase throws an exception + $cache = \ObjectCache::getLocalClusterInstance(); + $backoffKey = wfMemcKey( 'AuthManager', 'autocreate-failed', md5( $username ) ); + $this->assertFalse( $cache->get( $backoffKey ), 'sanity check' ); + $session->clear(); + $user = $this->getMock( 'User', [ 'addToDatabase' ] ); + $user->expects( $this->once() )->method( 'addToDatabase' ) + ->will( $this->throwException( new \Exception( 'Excepted' ) ) ); + $user->setName( $username ); + try { + $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \Exception $ex ) { + $this->assertSame( 'Excepted', $ex->getMessage() ); + } + $this->assertEquals( 0, $user->getId() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ], + [ LogLevel::ERROR, '{username} failed with exception {exception}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( null, $session->get( 'AuthManager::AutoCreateBlacklist' ) ); + $this->assertNotEquals( false, $cache->get( $backoffKey ) ); + $cache->delete( $backoffKey ); + + // Test addToDatabase fails because the user already exists. + $session->clear(); + $user = $this->getMock( 'User', [ 'addToDatabase' ] ); + $user->expects( $this->once() )->method( 'addToDatabase' ) + ->will( $this->returnCallback( function () use ( $username, &$user ) { + $oldUser = \User::newFromName( $username ); + $status = $oldUser->addToDatabase(); + $this->assertTrue( $status->isOK(), 'sanity check' ); + $user->setId( $oldUser->getId() ); + return \Status::newFatal( 'userexists' ); + } ) ); + $user->setName( $username ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $expect = \Status::newGood(); + $expect->warning( 'userexists' ); + $this->assertEquals( $expect, $ret ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertEquals( $username, $user->getName() ); + $this->assertEquals( $user->getId(), $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ], + [ LogLevel::INFO, '{username} already exists locally (race)' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( null, $session->get( 'AuthManager::AutoCreateBlacklist' ) ); + + // Success! + $session->clear(); + $username = self::usernameForCreation(); + $user = \User::newFromName( $username ); + $this->hook( 'AuthPluginAutoCreate', $this->once() ) + ->with( $callback ); + $this->hideDeprecated( 'AuthPluginAutoCreate hook (used in ' . + get_class( $wgHooks['AuthPluginAutoCreate'][0] ) . '::onAuthPluginAutoCreate)' ); + $this->hook( 'LocalUserCreated', $this->once() ) + ->with( $callback, $this->equalTo( true ) ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true ); + $this->unhook( 'LocalUserCreated' ); + $this->unhook( 'AuthPluginAutoCreate' ); + $this->assertEquals( \Status::newGood(), $ret ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertEquals( $username, $user->getName() ); + $this->assertEquals( $user->getId(), $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + $dbw = wfGetDB( DB_MASTER ); + $maxLogId = $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] ); + $session->clear(); + $username = self::usernameForCreation(); + $user = \User::newFromName( $username ); + $this->hook( 'LocalUserCreated', $this->once() ) + ->with( $callback, $this->equalTo( true ) ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, false ); + $this->unhook( 'LocalUserCreated' ); + $this->assertEquals( \Status::newGood(), $ret ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertEquals( $username, $user->getName() ); + $this->assertEquals( 0, $session->getUser()->getId() ); + $this->assertSame( [ + [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); + $this->assertSame( + $maxLogId, + $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] ) + ); + + $this->config->set( 'NewUserLog', true ); + $session->clear(); + $username = self::usernameForCreation(); + $user = \User::newFromName( $username ); + $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, false ); + $this->assertEquals( \Status::newGood(), $ret ); + $logger->clearBuffer(); + + $data = \DatabaseLogEntry::getSelectQueryData(); + $rows = iterator_to_array( $dbw->select( + $data['tables'], + $data['fields'], + [ + 'log_id > ' . (int)$maxLogId, + 'log_type' => 'newusers' + ] + $data['conds'], + __METHOD__, + $data['options'], + $data['join_conds'] + ) ); + $this->assertCount( 1, $rows ); + $entry = \DatabaseLogEntry::newFromRow( reset( $rows ) ); + + $this->assertSame( 'autocreate', $entry->getSubtype() ); + $this->assertSame( $user->getId(), $entry->getPerformer()->getId() ); + $this->assertSame( $user->getName(), $entry->getPerformer()->getName() ); + $this->assertSame( $user->getUserPage()->getFullText(), $entry->getTarget()->getFullText() ); + $this->assertSame( [ '4::userid' => $user->getId() ], $entry->getParameters() ); + + $workaroundPHPUnitBug = true; + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $expect + * @param array $state + */ + public function testGetAuthenticationRequests( $action, $expect, $state = [] ) { + $makeReq = function ( $key ) use ( $action ) { + $req = $this->getMock( AuthenticationRequest::class ); + $req->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + $req->action = $action === AuthManager::ACTION_UNLINK ? AuthManager::ACTION_REMOVE : $action; + $req->key = $key; + return $req; + }; + $cmpReqs = function ( $a, $b ) { + $ret = strcmp( get_class( $a ), get_class( $b ) ); + if ( !$ret ) { + $ret = strcmp( $a->key, $b->key ); + } + return $ret; + }; + + $good = StatusValue::newGood(); + + $mocks = []; + foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) { + $class = ucfirst( $key ) . 'AuthenticationProvider'; + $mocks[$key] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + $mocks[$key]->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnCallback( function ( $action ) use ( $key, $makeReq ) { + return [ $makeReq( "$key-$action" ), $makeReq( 'generic' ) ]; + } ) ); + $mocks[$key]->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' ) + ->will( $this->returnValue( $good ) ); + } + + $primaries = []; + foreach ( [ + PrimaryAuthenticationProvider::TYPE_NONE, + PrimaryAuthenticationProvider::TYPE_CREATE, + PrimaryAuthenticationProvider::TYPE_LINK + ] as $type ) { + $class = 'PrimaryAuthenticationProvider'; + $mocks["primary-$type"] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks["primary-$type"]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( "primary-$type" ) ); + $mocks["primary-$type"]->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( $type ) ); + $mocks["primary-$type"]->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnCallback( function ( $action ) use ( $type, $makeReq ) { + return [ $makeReq( "primary-$type-$action" ), $makeReq( 'generic' ) ]; + } ) ); + $mocks["primary-$type"]->expects( $this->any() ) + ->method( 'providerAllowsAuthenticationDataChange' ) + ->will( $this->returnValue( $good ) ); + $this->primaryauthMocks[] = $mocks["primary-$type"]; + } + + $mocks['primary2'] = $this->getMockForAbstractClass( + PrimaryAuthenticationProvider::class, [], "MockPrimaryAuthenticationProvider" + ); + $mocks['primary2']->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary2' ) ); + $mocks['primary2']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + $mocks['primary2']->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnValue( [] ) ); + $mocks['primary2']->expects( $this->any() ) + ->method( 'providerAllowsAuthenticationDataChange' ) + ->will( $this->returnCallback( function ( $req ) use ( $good ) { + return $req->key === 'generic' ? StatusValue::newFatal( 'no' ) : $good; + } ) ); + $this->primaryauthMocks[] = $mocks['primary2']; + + $this->preauthMocks = [ $mocks['pre'] ]; + $this->secondaryauthMocks = [ $mocks['secondary'] ]; + $this->initializeManager( true ); + + if ( $state ) { + if ( isset( $state['continueRequests'] ) ) { + $state['continueRequests'] = array_map( $makeReq, $state['continueRequests'] ); + } + if ( $action === AuthManager::ACTION_LOGIN_CONTINUE ) { + $this->request->getSession()->setSecret( 'AuthManager::authnState', $state ); + } elseif ( $action === AuthManager::ACTION_CREATE_CONTINUE ) { + $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', $state ); + } elseif ( $action === AuthManager::ACTION_LINK_CONTINUE ) { + $this->request->getSession()->setSecret( 'AuthManager::accountLinkState', $state ); + } + } + + $expectReqs = array_map( $makeReq, $expect ); + if ( $action === AuthManager::ACTION_LOGIN ) { + $req = new RememberMeAuthenticationRequest; + $req->action = $action; + $req->required = AuthenticationRequest::REQUIRED; + $expectReqs[] = $req; + } elseif ( $action === AuthManager::ACTION_CREATE ) { + $req = new UsernameAuthenticationRequest; + $req->action = $action; + $expectReqs[] = $req; + $req = new UserDataAuthenticationRequest; + $req->action = $action; + $req->required = AuthenticationRequest::REQUIRED; + $expectReqs[] = $req; + } + usort( $expectReqs, $cmpReqs ); + + $actual = $this->manager->getAuthenticationRequests( $action ); + foreach ( $actual as $req ) { + // Don't test this here. + $req->required = AuthenticationRequest::REQUIRED; + } + usort( $actual, $cmpReqs ); + + $this->assertEquals( $expectReqs, $actual ); + + // Test CreationReasonAuthenticationRequest gets returned + if ( $action === AuthManager::ACTION_CREATE ) { + $req = new CreationReasonAuthenticationRequest; + $req->action = $action; + $req->required = AuthenticationRequest::REQUIRED; + $expectReqs[] = $req; + usort( $expectReqs, $cmpReqs ); + + $actual = $this->manager->getAuthenticationRequests( $action, \User::newFromName( 'UTSysop' ) ); + foreach ( $actual as $req ) { + // Don't test this here. + $req->required = AuthenticationRequest::REQUIRED; + } + usort( $actual, $cmpReqs ); + + $this->assertEquals( $expectReqs, $actual ); + } + } + + public static function provideGetAuthenticationRequests() { + return [ + [ + AuthManager::ACTION_LOGIN, + [ 'pre-login', 'primary-none-login', 'primary-create-login', + 'primary-link-login', 'secondary-login', 'generic' ], + ], + [ + AuthManager::ACTION_CREATE, + [ 'pre-create', 'primary-none-create', 'primary-create-create', + 'primary-link-create', 'secondary-create', 'generic' ], + ], + [ + AuthManager::ACTION_LINK, + [ 'primary-link-link', 'generic' ], + ], + [ + AuthManager::ACTION_CHANGE, + [ 'primary-none-change', 'primary-create-change', 'primary-link-change', + 'secondary-change' ], + ], + [ + AuthManager::ACTION_REMOVE, + [ 'primary-none-remove', 'primary-create-remove', 'primary-link-remove', + 'secondary-remove' ], + ], + [ + AuthManager::ACTION_UNLINK, + [ 'primary-link-remove' ], + ], + [ + AuthManager::ACTION_LOGIN_CONTINUE, + [], + ], + [ + AuthManager::ACTION_LOGIN_CONTINUE, + $reqs = [ 'continue-login', 'foo', 'bar' ], + [ + 'continueRequests' => $reqs, + ], + ], + [ + AuthManager::ACTION_CREATE_CONTINUE, + [], + ], + [ + AuthManager::ACTION_CREATE_CONTINUE, + $reqs = [ 'continue-create', 'foo', 'bar' ], + [ + 'continueRequests' => $reqs, + ], + ], + [ + AuthManager::ACTION_LINK_CONTINUE, + [], + ], + [ + AuthManager::ACTION_LINK_CONTINUE, + $reqs = [ 'continue-link', 'foo', 'bar' ], + [ + 'continueRequests' => $reqs, + ], + ], + ]; + } + + public function testGetAuthenticationRequestsRequired() { + $makeReq = function ( $key, $required ) { + $req = $this->getMock( AuthenticationRequest::class ); + $req->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + $req->action = AuthManager::ACTION_LOGIN; + $req->key = $key; + $req->required = $required; + return $req; + }; + $cmpReqs = function ( $a, $b ) { + $ret = strcmp( get_class( $a ), get_class( $b ) ); + if ( !$ret ) { + $ret = strcmp( $a->key, $b->key ); + } + return $ret; + }; + + $good = StatusValue::newGood(); + + $primary1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $primary1->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary1' ) ); + $primary1->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $primary1->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnCallback( function ( $action ) use ( $makeReq ) { + return [ + $makeReq( "primary-shared", AuthenticationRequest::REQUIRED ), + $makeReq( "required", AuthenticationRequest::REQUIRED ), + $makeReq( "optional", AuthenticationRequest::OPTIONAL ), + $makeReq( "foo", AuthenticationRequest::REQUIRED ), + $makeReq( "bar", AuthenticationRequest::REQUIRED ), + $makeReq( "baz", AuthenticationRequest::OPTIONAL ), + ]; + } ) ); + + $primary2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $primary2->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'primary2' ) ); + $primary2->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $primary2->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnCallback( function ( $action ) use ( $makeReq ) { + return [ + $makeReq( "primary-shared", AuthenticationRequest::REQUIRED ), + $makeReq( "required2", AuthenticationRequest::REQUIRED ), + $makeReq( "optional2", AuthenticationRequest::OPTIONAL ), + ]; + } ) ); + + $secondary = $this->getMockForAbstractClass( SecondaryAuthenticationProvider::class ); + $secondary->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'secondary' ) ); + $secondary->expects( $this->any() )->method( 'getAuthenticationRequests' ) + ->will( $this->returnCallback( function ( $action ) use ( $makeReq ) { + return [ + $makeReq( "foo", AuthenticationRequest::OPTIONAL ), + $makeReq( "bar", AuthenticationRequest::REQUIRED ), + $makeReq( "baz", AuthenticationRequest::REQUIRED ), + ]; + } ) ); + + $rememberReq = new RememberMeAuthenticationRequest; + $rememberReq->action = AuthManager::ACTION_LOGIN; + + $this->primaryauthMocks = [ $primary1, $primary2 ]; + $this->secondaryauthMocks = [ $secondary ]; + $this->initializeManager( true ); + + $actual = $this->manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN ); + $expected = [ + $rememberReq, + $makeReq( "primary-shared", AuthenticationRequest::PRIMARY_REQUIRED ), + $makeReq( "required", AuthenticationRequest::PRIMARY_REQUIRED ), + $makeReq( "required2", AuthenticationRequest::PRIMARY_REQUIRED ), + $makeReq( "optional", AuthenticationRequest::OPTIONAL ), + $makeReq( "optional2", AuthenticationRequest::OPTIONAL ), + $makeReq( "foo", AuthenticationRequest::PRIMARY_REQUIRED ), + $makeReq( "bar", AuthenticationRequest::REQUIRED ), + $makeReq( "baz", AuthenticationRequest::REQUIRED ), + ]; + usort( $actual, $cmpReqs ); + usort( $expected, $cmpReqs ); + $this->assertEquals( $expected, $actual ); + + $this->primaryauthMocks = [ $primary1 ]; + $this->secondaryauthMocks = [ $secondary ]; + $this->initializeManager( true ); + + $actual = $this->manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN ); + $expected = [ + $rememberReq, + $makeReq( "primary-shared", AuthenticationRequest::PRIMARY_REQUIRED ), + $makeReq( "required", AuthenticationRequest::PRIMARY_REQUIRED ), + $makeReq( "optional", AuthenticationRequest::OPTIONAL ), + $makeReq( "foo", AuthenticationRequest::PRIMARY_REQUIRED ), + $makeReq( "bar", AuthenticationRequest::REQUIRED ), + $makeReq( "baz", AuthenticationRequest::REQUIRED ), + ]; + usort( $actual, $cmpReqs ); + usort( $expected, $cmpReqs ); + $this->assertEquals( $expected, $actual ); + } + + public function testAllowsPropertyChange() { + $mocks = []; + foreach ( [ 'primary', 'secondary' ] as $key ) { + $class = ucfirst( $key ) . 'AuthenticationProvider'; + $mocks[$key] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + $mocks[$key]->expects( $this->any() )->method( 'providerAllowsPropertyChange' ) + ->will( $this->returnCallback( function ( $prop ) use ( $key ) { + return $prop !== $key; + } ) ); + } + + $this->primaryauthMocks = [ $mocks['primary'] ]; + $this->secondaryauthMocks = [ $mocks['secondary'] ]; + $this->initializeManager( true ); + + $this->assertTrue( $this->manager->allowsPropertyChange( 'foo' ) ); + $this->assertFalse( $this->manager->allowsPropertyChange( 'primary' ) ); + $this->assertFalse( $this->manager->allowsPropertyChange( 'secondary' ) ); + } + + public function testAutoCreateOnLogin() { + $username = self::usernameForCreation(); + + $req = $this->getMock( AuthenticationRequest::class ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'primary' ) ); + $mock->expects( $this->any() )->method( 'beginPrimaryAuthentication' ) + ->will( $this->returnValue( AuthenticationResponse::newPass( $username ) ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( true ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + + $mock2 = $this->getMockForAbstractClass( SecondaryAuthenticationProvider::class ); + $mock2->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( 'secondary' ) ); + $mock2->expects( $this->any() )->method( 'beginSecondaryAuthentication' )->will( + $this->returnValue( + AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ) + ) + ); + $mock2->expects( $this->any() )->method( 'continueSecondaryAuthentication' ) + ->will( $this->returnValue( AuthenticationResponse::newAbstain() ) ); + $mock2->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + + $this->primaryauthMocks = [ $mock ]; + $this->secondaryauthMocks = [ $mock2 ]; + $this->initializeManager( true ); + $this->manager->setLogger( new \Psr\Log\NullLogger() ); + $session = $this->request->getSession(); + $session->clear(); + + $this->assertSame( 0, \User::newFromName( $username )->getId(), + 'sanity check' ); + + $callback = $this->callback( function ( $user ) use ( $username ) { + return $user->getName() === $username; + } ); + + $this->hook( 'UserLoggedIn', $this->never() ); + $this->hook( 'LocalUserCreated', $this->once() )->with( $callback, $this->equalTo( true ) ); + $ret = $this->manager->beginAuthentication( [], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->unhook( 'UserLoggedIn' ); + $this->assertSame( AuthenticationResponse::UI, $ret->status ); + + $id = (int)\User::newFromName( $username )->getId(); + $this->assertNotSame( 0, \User::newFromName( $username )->getId() ); + $this->assertSame( 0, $session->getUser()->getId() ); + + $this->hook( 'UserLoggedIn', $this->once() )->with( $callback ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->continueAuthentication( [] ); + $this->unhook( 'LocalUserCreated' ); + $this->unhook( 'UserLoggedIn' ); + $this->assertSame( AuthenticationResponse::PASS, $ret->status ); + $this->assertSame( $username, $ret->username ); + $this->assertSame( $id, $session->getUser()->getId() ); + } + + public function testAutoCreateFailOnLogin() { + $username = self::usernameForCreation(); + + $mock = $this->getMockForAbstractClass( + PrimaryAuthenticationProvider::class, [], "MockPrimaryAuthenticationProvider" ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'primary' ) ); + $mock->expects( $this->any() )->method( 'beginPrimaryAuthentication' ) + ->will( $this->returnValue( AuthenticationResponse::newPass( $username ) ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( true ) ); + $mock->expects( $this->any() )->method( 'testUserForCreation' ) + ->will( $this->returnValue( StatusValue::newFatal( 'fail-from-primary' ) ) ); + + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + $this->manager->setLogger( new \Psr\Log\NullLogger() ); + $session = $this->request->getSession(); + $session->clear(); + + $this->assertSame( 0, $session->getUser()->getId(), + 'sanity check' ); + $this->assertSame( 0, \User::newFromName( $username )->getId(), + 'sanity check' ); + + $this->hook( 'UserLoggedIn', $this->never() ); + $this->hook( 'LocalUserCreated', $this->never() ); + $ret = $this->manager->beginAuthentication( [], 'http://localhost/' ); + $this->unhook( 'LocalUserCreated' ); + $this->unhook( 'UserLoggedIn' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'authmanager-authn-autocreate-failed', $ret->message->getKey() ); + + $this->assertSame( 0, \User::newFromName( $username )->getId() ); + $this->assertSame( 0, $session->getUser()->getId() ); + } + + public function testAuthenticationSessionData() { + $this->initializeManager( true ); + + $this->assertNull( $this->manager->getAuthenticationSessionData( 'foo' ) ); + $this->manager->setAuthenticationSessionData( 'foo', 'foo!' ); + $this->manager->setAuthenticationSessionData( 'bar', 'bar!' ); + $this->assertSame( 'foo!', $this->manager->getAuthenticationSessionData( 'foo' ) ); + $this->assertSame( 'bar!', $this->manager->getAuthenticationSessionData( 'bar' ) ); + $this->manager->removeAuthenticationSessionData( 'foo' ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'foo' ) ); + $this->assertSame( 'bar!', $this->manager->getAuthenticationSessionData( 'bar' ) ); + $this->manager->removeAuthenticationSessionData( 'bar' ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'bar' ) ); + + $this->manager->setAuthenticationSessionData( 'foo', 'foo!' ); + $this->manager->setAuthenticationSessionData( 'bar', 'bar!' ); + $this->manager->removeAuthenticationSessionData( null ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'foo' ) ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'bar' ) ); + } + + public function testCanLinkAccounts() { + $types = [ + PrimaryAuthenticationProvider::TYPE_CREATE => true, + PrimaryAuthenticationProvider::TYPE_LINK => true, + PrimaryAuthenticationProvider::TYPE_NONE => false, + ]; + + foreach ( $types as $type => $can ) { + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( $type ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( $type ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + $this->assertSame( $can, $this->manager->canCreateAccounts(), $type ); + } + } + + public function testBeginAccountLink() { + $user = \User::newFromName( 'UTSysop' ); + $this->initializeManager(); + + $this->request->getSession()->setSecret( 'AuthManager::accountLinkState', 'test' ); + try { + $this->manager->beginAccountLink( $user, [], 'http://localhost/' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \LogicException $ex ) { + $this->assertEquals( 'Account linking is not possible', $ex->getMessage() ); + } + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ) ); + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $ret = $this->manager->beginAccountLink( new \User, [], 'http://localhost/' ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'noname', $ret->message->getKey() ); + + $ret = $this->manager->beginAccountLink( + \User::newFromName( 'UTDoesNotExist' ), [], 'http://localhost/' + ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'authmanager-userdoesnotexist', $ret->message->getKey() ); + } + + public function testContinueAccountLink() { + $user = \User::newFromName( 'UTSysop' ); + $this->initializeManager(); + + $session = [ + 'userid' => $user->getId(), + 'username' => $user->getName(), + 'primary' => 'X', + ]; + + try { + $this->manager->continueAccountLink( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \LogicException $ex ) { + $this->assertEquals( 'Account linking is not possible', $ex->getMessage() ); + } + + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); + $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) ); + $mock->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + $mock->expects( $this->any() )->method( 'beginPrimaryAccountLink' )->will( + $this->returnValue( AuthenticationResponse::newFail( $this->message( 'fail' ) ) ) + ); + $this->primaryauthMocks = [ $mock ]; + $this->initializeManager( true ); + + $this->request->getSession()->setSecret( 'AuthManager::accountLinkState', null ); + $ret = $this->manager->continueAccountLink( [] ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'authmanager-link-not-in-progress', $ret->message->getKey() ); + + $this->request->getSession()->setSecret( 'AuthManager::accountLinkState', + [ 'username' => $user->getName() . '<>' ] + $session ); + $ret = $this->manager->continueAccountLink( [] ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'noname', $ret->message->getKey() ); + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ) ); + + $id = $user->getId(); + $this->request->getSession()->setSecret( 'AuthManager::accountLinkState', + [ 'userid' => $id + 1 ] + $session ); + try { + $ret = $this->manager->continueAccountLink( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertEquals( + "User \"{$user->getName()}\" is valid, but ID $id != " . ( $id + 1 ) . '!', + $ex->getMessage() + ); + } + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ) ); + } + + /** + * @dataProvider provideAccountLink + * @param StatusValue $preTest + * @param array $primaryResponses + * @param array $managerResponses + */ + public function testAccountLink( + StatusValue $preTest, array $primaryResponses, array $managerResponses + ) { + $user = \User::newFromName( 'UTSysop' ); + + $this->initializeManager(); + + // Set up lots of mocks... + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $req->primary = $primaryResponses; + $mocks = []; + + foreach ( [ 'pre', 'primary' ] as $key ) { + $class = ucfirst( $key ) . 'AuthenticationProvider'; + $mocks[$key] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key ) ); + + for ( $i = 2; $i <= 3; $i++ ) { + $mocks[$key . $i] = $this->getMockForAbstractClass( + "MediaWiki\\Auth\\$class", [], "Mock$class" + ); + $mocks[$key . $i]->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( $key . $i ) ); + } + } + + $mocks['pre']->expects( $this->any() )->method( 'testForAccountLink' ) + ->will( $this->returnCallback( + function ( $u ) + use ( $user, $preTest ) + { + $this->assertSame( $user->getId(), $u->getId() ); + $this->assertSame( $user->getName(), $u->getName() ); + return $preTest; + } + ) ); + + $mocks['pre2']->expects( $this->atMost( 1 ) )->method( 'testForAccountLink' ) + ->will( $this->returnValue( StatusValue::newGood() ) ); + + $mocks['primary']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + $ct = count( $req->primary ); + $callback = $this->returnCallback( function ( $u, $reqs ) use ( $user, $req ) { + $this->assertSame( $user->getId(), $u->getId() ); + $this->assertSame( $user->getName(), $u->getName() ); + $foundReq = false; + foreach ( $reqs as $r ) { + $this->assertSame( $user->getName(), $r->username ); + $foundReq = $foundReq || get_class( $r ) === get_class( $req ); + } + $this->assertTrue( $foundReq, '$reqs contains $req' ); + return array_shift( $req->primary ); + } ); + $mocks['primary']->expects( $this->exactly( min( 1, $ct ) ) ) + ->method( 'beginPrimaryAccountLink' ) + ->will( $callback ); + $mocks['primary']->expects( $this->exactly( max( 0, $ct - 1 ) ) ) + ->method( 'continuePrimaryAccountLink' ) + ->will( $callback ); + + $abstain = AuthenticationResponse::newAbstain(); + $mocks['primary2']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) ); + $mocks['primary2']->expects( $this->atMost( 1 ) )->method( 'beginPrimaryAccountLink' ) + ->will( $this->returnValue( $abstain ) ); + $mocks['primary2']->expects( $this->never() )->method( 'continuePrimaryAccountLink' ); + $mocks['primary3']->expects( $this->any() )->method( 'accountCreationType' ) + ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) ); + $mocks['primary3']->expects( $this->never() )->method( 'beginPrimaryAccountLink' ); + $mocks['primary3']->expects( $this->never() )->method( 'continuePrimaryAccountLink' ); + + $this->preauthMocks = [ $mocks['pre'], $mocks['pre2'] ]; + $this->primaryauthMocks = [ $mocks['primary3'], $mocks['primary2'], $mocks['primary'] ]; + $this->logger = new \TestLogger( true, function ( $message, $level ) { + return $level === LogLevel::DEBUG ? null : $message; + } ); + $this->initializeManager( true ); + + $constraint = \PHPUnit_Framework_Assert::logicalOr( + $this->equalTo( AuthenticationResponse::PASS ), + $this->equalTo( AuthenticationResponse::FAIL ) + ); + $providers = array_merge( $this->preauthMocks, $this->primaryauthMocks ); + foreach ( $providers as $p ) { + $p->postCalled = false; + $p->expects( $this->atMost( 1 ) )->method( 'postAccountLink' ) + ->willReturnCallback( function ( $user, $response ) use ( $constraint, $p ) { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( 'UTSysop', $user->getName() ); + $this->assertInstanceOf( AuthenticationResponse::class, $response ); + $this->assertThat( $response->status, $constraint ); + $p->postCalled = $response->status; + } ); + } + + $first = true; + $created = false; + $expectLog = []; + foreach ( $managerResponses as $i => $response ) { + if ( $response instanceof AuthenticationResponse && + $response->status === AuthenticationResponse::PASS + ) { + $expectLog[] = [ LogLevel::INFO, 'Account linked to {user} by primary' ]; + } + + $ex = null; + try { + if ( $first ) { + $ret = $this->manager->beginAccountLink( $user, [ $req ], 'http://localhost/' ); + } else { + $ret = $this->manager->continueAccountLink( [ $req ] ); + } + if ( $response instanceof \Exception ) { + $this->fail( 'Expected exception not thrown', "Response $i" ); + } + } catch ( \Exception $ex ) { + if ( !$response instanceof \Exception ) { + throw $ex; + } + $this->assertEquals( $response->getMessage(), $ex->getMessage(), "Response $i, exception" ); + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ), + "Response $i, exception, session state" ); + return; + } + + $this->assertSame( 'http://localhost/', $req->returnToUrl ); + + $ret->message = $this->message( $ret->message ); + $this->assertEquals( $response, $ret, "Response $i, response" ); + if ( $response->status === AuthenticationResponse::PASS || + $response->status === AuthenticationResponse::FAIL + ) { + $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ), + "Response $i, session state" ); + foreach ( $providers as $p ) { + $this->assertSame( $response->status, $p->postCalled, + "Response $i, post-auth callback called" ); + } + } else { + $this->assertNotNull( + $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ), + "Response $i, session state" + ); + foreach ( $ret->neededRequests as $neededReq ) { + $this->assertEquals( AuthManager::ACTION_LINK, $neededReq->action, + "Response $i, neededRequest action" ); + } + $this->assertEquals( + $ret->neededRequests, + $this->manager->getAuthenticationRequests( AuthManager::ACTION_LINK_CONTINUE ), + "Response $i, continuation check" + ); + foreach ( $providers as $p ) { + $this->assertFalse( $p->postCalled, "Response $i, post-auth callback not called" ); + } + } + + $first = false; + } + + $this->assertSame( $expectLog, $this->logger->getBuffer() ); + } + + public function provideAccountLink() { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $good = StatusValue::newGood(); + + return [ + 'Pre-link test fail in pre' => [ + StatusValue::newFatal( 'fail-from-pre' ), + [], + [ + AuthenticationResponse::newFail( $this->message( 'fail-from-pre' ) ), + ] + ], + 'Failure in primary' => [ + $good, + $tmp = [ + AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ), + ], + $tmp + ], + 'All primary abstain' => [ + $good, + [ + AuthenticationResponse::newAbstain(), + ], + [ + AuthenticationResponse::newFail( $this->message( 'authmanager-link-no-primary' ) ) + ] + ], + 'Primary UI, then redirect, then fail' => [ + $good, + $tmp = [ + AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newRedirect( [ $req ], '/foo.html', [ 'foo' => 'bar' ] ), + AuthenticationResponse::newFail( $this->message( 'fail-in-primary-continue' ) ), + ], + $tmp + ], + 'Primary redirect, then abstain' => [ + $good, + [ + $tmp = AuthenticationResponse::newRedirect( + [ $req ], '/foo.html', [ 'foo' => 'bar' ] + ), + AuthenticationResponse::newAbstain(), + ], + [ + $tmp, + new \DomainException( + 'MockPrimaryAuthenticationProvider::continuePrimaryAccountLink() returned ABSTAIN' + ) + ] + ], + 'Primary UI, then pass' => [ + $good, + [ + $tmp1 = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ), + AuthenticationResponse::newPass(), + ], + [ + $tmp1, + AuthenticationResponse::newPass( '' ), + ] + ], + 'Primary pass' => [ + $good, + [ + AuthenticationResponse::newPass( '' ), + ], + [ + AuthenticationResponse::newPass( '' ), + ] + ], + ]; + } +} diff --git a/tests/phpunit/includes/auth/AuthPluginPrimaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/AuthPluginPrimaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..96e50e07ac --- /dev/null +++ b/tests/phpunit/includes/auth/AuthPluginPrimaryAuthenticationProviderTest.php @@ -0,0 +1,716 @@ +fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'Trying to wrap AuthManagerAuthPlugin in AuthPluginPrimaryAuthenticationProvider ' . + 'makes no sense.', + $ex->getMessage() + ); + } + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + [ new PasswordAuthenticationRequest ], + $provider->getAuthenticationRequests( AuthManager::ACTION_LOGIN, [] ) + ); + + $req = $this->getMock( PasswordAuthenticationRequest::class ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin, get_class( $req ) ); + $this->assertEquals( + [ $req ], + $provider->getAuthenticationRequests( AuthManager::ACTION_LOGIN, [] ) + ); + + $reqType = get_class( $this->getMock( AuthenticationRequest::class ) ); + try { + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin, $reqType ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + "$reqType is not a MediaWiki\\Auth\\PasswordAuthenticationRequest", + $ex->getMessage() + ); + } + } + + public function testOnUserSaveSettings() { + $user = \User::newFromName( 'UTSysop' ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'updateExternalDB' ) + ->with( $this->identicalTo( $user ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + \Hooks::run( 'UserSaveSettings', [ $user ] ); + } + + public function testOnUserGroupsChanged() { + $user = \User::newFromName( 'UTSysop' ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'updateExternalDBGroups' ) + ->with( + $this->identicalTo( $user ), + $this->identicalTo( [ 'added' ] ), + $this->identicalTo( [ 'removed' ] ) + ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + \Hooks::run( 'UserGroupsChanged', [ $user, [ 'added' ], [ 'removed' ] ] ); + } + + public function testOnUserLoggedIn() { + $user = \User::newFromName( 'UTSysop' ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->exactly( 2 ) )->method( 'updateUser' ) + ->with( $this->identicalTo( $user ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + \Hooks::run( 'UserLoggedIn', [ $user ] ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'updateUser' ) + ->will( $this->returnCallback( function ( &$user ) { + $user = \User::newFromName( 'UTSysop' ); + } ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + try { + \Hooks::run( 'UserLoggedIn', [ $user ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + get_class( $plugin ) . '::updateUser() tried to replace $user!', + $ex->getMessage() + ); + } + } + + public function testOnLocalUserCreated() { + $user = \User::newFromName( 'UTSysop' ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->exactly( 2 ) )->method( 'initUser' ) + ->with( $this->identicalTo( $user ), $this->identicalTo( false ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + \Hooks::run( 'LocalUserCreated', [ $user, false ] ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'initUser' ) + ->will( $this->returnCallback( function ( &$user ) { + $user = \User::newFromName( 'UTSysop' ); + } ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + try { + \Hooks::run( 'LocalUserCreated', [ $user, false ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + get_class( $plugin ) . '::initUser() tried to replace $user!', + $ex->getMessage() + ); + } + } + + public function testGetUniqueId() { + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertSame( + 'MediaWiki\\Auth\\AuthPluginPrimaryAuthenticationProvider:' . get_class( $plugin ), + $provider->getUniqueId() + ); + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $response + * @param bool $allowPasswordChange + */ + public function testGetAuthenticationRequests( $action, $response, $allowPasswordChange ) { + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->any() )->method( 'allowPasswordChange' ) + ->will( $this->returnValue( $allowPasswordChange ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public static function provideGetAuthenticationRequests() { + $arr = [ new PasswordAuthenticationRequest() ]; + return [ + [ AuthManager::ACTION_LOGIN, $arr, true ], + [ AuthManager::ACTION_LOGIN, $arr, false ], + [ AuthManager::ACTION_CREATE, $arr, true ], + [ AuthManager::ACTION_CREATE, $arr, false ], + [ AuthManager::ACTION_LINK, [], true ], + [ AuthManager::ACTION_LINK, [], false ], + [ AuthManager::ACTION_CHANGE, $arr, true ], + [ AuthManager::ACTION_CHANGE, [], false ], + [ AuthManager::ACTION_REMOVE, $arr, true ], + [ AuthManager::ACTION_REMOVE, [], false ], + ]; + } + + public function testAuthentication() { + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_LOGIN; + $reqs = [ PasswordAuthenticationRequest::class => $req ]; + + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'authenticate' ] ) + ->getMock(); + $plugin->expects( $this->never() )->method( 'authenticate' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( [] ) + ); + + $req->username = 'foo'; + $req->password = null; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = null; + $req->password = 'bar'; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = 'foo'; + $req->password = 'bar'; + + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'userExists', 'authenticate' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'authenticate' ) + ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + AuthenticationResponse::newPass( 'Foo', $req ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'userExists', 'authenticate' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( false ) ); + $plugin->expects( $this->never() )->method( 'authenticate' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $pluginUser = $this->getMockBuilder( 'AuthPluginUser' ) + ->setMethods( [ 'isLocked' ] ) + ->disableOriginalConstructor() + ->getMock(); + $pluginUser->expects( $this->once() )->method( 'isLocked' ) + ->will( $this->returnValue( true ) ); + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'userExists', 'getUserInstance', 'authenticate' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'getUserInstance' ) + ->will( $this->returnValue( $pluginUser ) ); + $plugin->expects( $this->never() )->method( 'authenticate' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'userExists', 'authenticate' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'authenticate' ) + ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( false ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'userExists', 'authenticate', 'strict' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'authenticate' ) + ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( false ) ); + $plugin->expects( $this->any() )->method( 'strict' )->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'wrongpassword', $ret->message->getKey() ); + + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'userExists', 'authenticate', 'strictUserAuth' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'authenticate' ) + ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( false ) ); + $plugin->expects( $this->any() )->method( 'strictUserAuth' ) + ->with( $this->equalTo( 'Foo' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'wrongpassword', $ret->message->getKey() ); + + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'domainList', 'validDomain', 'setDomain', 'userExists', 'authenticate' ] ) + ->getMock(); + $plugin->expects( $this->any() )->method( 'domainList' ) + ->will( $this->returnValue( [ 'Domain1', 'Domain2' ] ) ); + $plugin->expects( $this->any() )->method( 'validDomain' ) + ->will( $this->returnCallback( function ( $domain ) { + return in_array( $domain, [ 'Domain1', 'Domain2' ] ); + } ) ); + $plugin->expects( $this->once() )->method( 'setDomain' ) + ->with( $this->equalTo( 'Domain2' ) ); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'authenticate' ) + ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + list( $req ) = $provider->getAuthenticationRequests( AuthManager::ACTION_LOGIN, [] ); + $req->username = 'foo'; + $req->password = 'bar'; + $req->domain = 'Domain2'; + $provider->beginPrimaryAuthentication( [ $req ] ); + } + + public function testTestUserExists() { + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->with( $this->equalTo( 'Foo' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + $this->assertTrue( $provider->testUserExists( 'foo' ) ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->with( $this->equalTo( 'Foo' ) ) + ->will( $this->returnValue( false ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + $this->assertFalse( $provider->testUserExists( 'foo' ) ); + } + + public function testTestUserCanAuthenticate() { + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->with( $this->equalTo( 'Foo' ) ) + ->will( $this->returnValue( false ) ); + $plugin->expects( $this->never() )->method( 'getUserInstance' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertFalse( $provider->testUserCanAuthenticate( 'foo' ) ); + + $pluginUser = $this->getMockBuilder( 'AuthPluginUser' ) + ->disableOriginalConstructor() + ->getMock(); + $pluginUser->expects( $this->once() )->method( 'isLocked' ) + ->will( $this->returnValue( true ) ); + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->with( $this->equalTo( 'Foo' ) ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'getUserInstance' ) + ->with( $this->callback( function ( $user ) { + $this->assertInstanceOf( 'User', $user ); + $this->assertEquals( 'Foo', $user->getName() ); + return true; + } ) ) + ->will( $this->returnValue( $pluginUser ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertFalse( $provider->testUserCanAuthenticate( 'foo' ) ); + + $pluginUser = $this->getMockBuilder( 'AuthPluginUser' ) + ->disableOriginalConstructor() + ->getMock(); + $pluginUser->expects( $this->once() )->method( 'isLocked' ) + ->will( $this->returnValue( false ) ); + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'userExists' ) + ->with( $this->equalTo( 'Foo' ) ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'getUserInstance' ) + ->with( $this->callback( function ( $user ) { + $this->assertInstanceOf( 'User', $user ); + $this->assertEquals( 'Foo', $user->getName() ); + return true; + } ) ) + ->will( $this->returnValue( $pluginUser ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertTrue( $provider->testUserCanAuthenticate( 'foo' ) ); + } + + public function testProviderRevokeAccessForUser() { + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'userExists', 'setPassword' ] ) + ->getMock(); + $plugin->expects( $this->once() )->method( 'userExists' )->willReturn( true ); + $plugin->expects( $this->once() )->method( 'setPassword' ) + ->with( $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), $this->identicalTo( null ) ) + ->willReturn( true ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $provider->providerRevokeAccessForUser( 'foo' ); + + $plugin = $this->getMockBuilder( 'AuthPlugin' ) + ->setMethods( [ 'domainList', 'userExists', 'setPassword' ] ) + ->getMock(); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [ 'D1', 'D2', 'D3' ] ); + $plugin->expects( $this->exactly( 3 ) )->method( 'userExists' ) + ->willReturnCallback( function () use ( $plugin ) { + return $plugin->getDomain() !== 'D2'; + } ); + $plugin->expects( $this->exactly( 2 ) )->method( 'setPassword' ) + ->with( $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), $this->identicalTo( null ) ) + ->willReturnCallback( function () use ( $plugin ) { + $this->assertNotEquals( 'D2', $plugin->getDomain() ); + return $plugin->getDomain() !== 'D1'; + } ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + try { + $provider->providerRevokeAccessForUser( 'foo' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'AuthPlugin failed to reset password for Foo in the following domains: D1', + $ex->getMessage() + ); + } + } + + public function testProviderAllowsPropertyChange() { + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->any() )->method( 'allowPropChange' ) + ->will( $this->returnCallback( function ( $prop ) { + return $prop === 'allow'; + } ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + $this->assertTrue( $provider->providerAllowsPropertyChange( 'allow' ) ); + $this->assertFalse( $provider->providerAllowsPropertyChange( 'deny' ) ); + } + + /** + * @dataProvider provideProviderAllowsAuthenticationDataChange + * @param string $type + * @param bool|null $allow + * @param StatusValue $expect + */ + public function testProviderAllowsAuthenticationDataChange( $type, $allow, $expect ) { + $domains = $type instanceof PasswordDomainAuthenticationRequest ? [ 'foo', 'bar' ] : []; + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( $domains ); + $plugin->expects( $allow === null ? $this->never() : $this->once() ) + ->method( 'allowPasswordChange' )->will( $this->returnValue( $allow ) ); + $plugin->expects( $this->any() )->method( 'validDomain' ) + ->willReturnCallback( function ( $d ) use ( $domains ) { + return in_array( $d, $domains, true ); + } ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + if ( is_object( $type ) ) { + $req = $type; + } else { + $req = $this->getMock( $type ); + } + $req->action = AuthManager::ACTION_CHANGE; + $req->username = 'UTSysop'; + $req->password = 'Pa$$w0Rd!!!'; + $req->retype = 'Pa$$w0Rd!!!'; + $this->assertEquals( $expect, $provider->providerAllowsAuthenticationDataChange( $req ) ); + } + + public static function provideProviderAllowsAuthenticationDataChange() { + $domains = [ 'foo', 'bar' ]; + $reqNoDomain = new PasswordDomainAuthenticationRequest( $domains ); + $reqValidDomain = new PasswordDomainAuthenticationRequest( $domains ); + $reqValidDomain->domain = 'foo'; + $reqInvalidDomain = new PasswordDomainAuthenticationRequest( $domains ); + $reqInvalidDomain->domain = 'invalid'; + + return [ + [ AuthenticationRequest::class, null, \StatusValue::newGood( 'ignored' ) ], + [ new PasswordAuthenticationRequest, true, \StatusValue::newGood() ], + [ + new PasswordAuthenticationRequest, + false, + \StatusValue::newFatal( 'authmanager-authplugin-setpass-denied' ) + ], + [ $reqNoDomain, true, \StatusValue::newGood( 'ignored' ) ], + [ $reqValidDomain, true, \StatusValue::newGood() ], + [ + $reqInvalidDomain, + true, + \StatusValue::newFatal( 'authmanager-authplugin-setpass-bad-domain' ) + ], + ]; + } + + public function testProviderChangeAuthenticationData() { + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->never() )->method( 'setPassword' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $provider->providerChangeAuthenticationData( + $this->getMock( AuthenticationRequest::class ) + ); + + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_CHANGE; + $req->username = 'foo'; + $req->password = 'bar'; + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'setPassword' ) + ->with( $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $provider->providerChangeAuthenticationData( $req ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() )->method( 'setPassword' ) + ->with( $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( false ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + try { + $provider->providerChangeAuthenticationData( $req ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \ErrorPageError $e ) { + $this->assertSame( 'authmanager-authplugin-setpass-failed-title', $e->title ); + $this->assertSame( 'authmanager-authplugin-setpass-failed-message', $e->msg ); + } + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' ) + ->will( $this->returnValue( [ 'Domain1', 'Domain2' ] ) ); + $plugin->expects( $this->any() )->method( 'validDomain' ) + ->will( $this->returnCallback( function ( $domain ) { + return in_array( $domain, [ 'Domain1', 'Domain2' ] ); + } ) ); + $plugin->expects( $this->once() )->method( 'setDomain' ) + ->with( $this->equalTo( 'Domain2' ) ); + $plugin->expects( $this->once() )->method( 'setPassword' ) + ->with( $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + list( $req ) = $provider->getAuthenticationRequests( AuthManager::ACTION_CREATE, [] ); + $req->username = 'foo'; + $req->password = 'bar'; + $req->domain = 'Domain2'; + $provider->providerChangeAuthenticationData( $req ); + } + + /** + * @dataProvider provideAccountCreationType + * @param bool $can + * @param string $expect + */ + public function testAccountCreationType( $can, $expect ) { + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->once() ) + ->method( 'canCreateAccounts' )->will( $this->returnValue( $can ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + $this->assertSame( $expect, $provider->accountCreationType() ); + } + + public static function provideAccountCreationType() { + return [ + [ true, PrimaryAuthenticationProvider::TYPE_CREATE ], + [ false, PrimaryAuthenticationProvider::TYPE_NONE ], + ]; + } + + public function testTestForAccountCreation() { + $user = \User::newFromName( 'foo' ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ) + ); + } + + public function testAccountCreation() { + $user = \User::newFromName( 'foo' ); + $user->setEmail( 'email' ); + $user->setRealName( 'realname' ); + + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_CREATE; + $reqs = [ PasswordAuthenticationRequest::class => $req ]; + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->any() )->method( 'canCreateAccounts' ) + ->will( $this->returnValue( false ) ); + $plugin->expects( $this->never() )->method( 'addUser' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + try { + $provider->beginPrimaryAccountCreation( $user, $user, [] ); + $this->fail( 'Expected exception was not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( + 'Shouldn\'t call this when accountCreationType() is NONE', $ex->getMessage() + ); + } + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->any() )->method( 'canCreateAccounts' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->never() )->method( 'addUser' ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, [] ) + ); + + $req->username = 'foo'; + $req->password = null; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $req->username = null; + $req->password = 'bar'; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $req->username = 'foo'; + $req->password = 'bar'; + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->any() )->method( 'canCreateAccounts' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'addUser' ) + ->with( + $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), + $this->equalTo( 'bar' ), + $this->equalTo( 'email' ), + $this->equalTo( 'realname' ) + ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $this->assertEquals( + AuthenticationResponse::newPass(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] ); + $plugin->expects( $this->any() )->method( 'canCreateAccounts' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->once() )->method( 'addUser' ) + ->with( + $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), + $this->equalTo( 'bar' ), + $this->equalTo( 'email' ), + $this->equalTo( 'realname' ) + ) + ->will( $this->returnValue( false ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + $ret = $provider->beginPrimaryAccountCreation( $user, $user, $reqs ); + $this->assertSame( AuthenticationResponse::FAIL, $ret->status ); + $this->assertSame( 'authmanager-authplugin-create-fail', $ret->message->getKey() ); + + $plugin = $this->getMock( 'AuthPlugin' ); + $plugin->expects( $this->any() )->method( 'canCreateAccounts' ) + ->will( $this->returnValue( true ) ); + $plugin->expects( $this->any() )->method( 'domainList' ) + ->will( $this->returnValue( [ 'Domain1', 'Domain2' ] ) ); + $plugin->expects( $this->any() )->method( 'validDomain' ) + ->will( $this->returnCallback( function ( $domain ) { + return in_array( $domain, [ 'Domain1', 'Domain2' ] ); + } ) ); + $plugin->expects( $this->once() )->method( 'setDomain' ) + ->with( $this->equalTo( 'Domain2' ) ); + $plugin->expects( $this->once() )->method( 'addUser' ) + ->with( $this->callback( function ( $u ) { + return $u instanceof \User && $u->getName() === 'Foo'; + } ), $this->equalTo( 'bar' ) ) + ->will( $this->returnValue( true ) ); + $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin ); + list( $req ) = $provider->getAuthenticationRequests( AuthManager::ACTION_CREATE, [] ); + $req->username = 'foo'; + $req->password = 'bar'; + $req->domain = 'Domain2'; + $provider->beginPrimaryAccountCreation( $user, $user, [ $req ] ); + } + +} diff --git a/tests/phpunit/includes/auth/AuthenticationRequestTest.php b/tests/phpunit/includes/auth/AuthenticationRequestTest.php new file mode 100644 index 0000000000..7d2ba8d749 --- /dev/null +++ b/tests/phpunit/includes/auth/AuthenticationRequestTest.php @@ -0,0 +1,517 @@ +getMockForAbstractClass( AuthenticationRequest::class ); + + $this->assertSame( get_class( $mock ), $mock->getUniqueId() ); + + $this->assertType( 'array', $mock->getMetadata() ); + + $ret = $mock->describeCredentials(); + $this->assertInternalType( 'array', $ret ); + $this->assertArrayHasKey( 'provider', $ret ); + $this->assertInstanceOf( 'Message', $ret['provider'] ); + $this->assertArrayHasKey( 'account', $ret ); + $this->assertInstanceOf( 'Message', $ret['account'] ); + } + + public function testLoadRequestsFromSubmission() { + $mb = $this->getMockBuilder( AuthenticationRequest::class ) + ->setMethods( [ 'loadFromSubmission' ] ); + + $data = [ 'foo', 'bar' ]; + + $req1 = $mb->getMockForAbstractClass(); + $req1->expects( $this->once() )->method( 'loadFromSubmission' ) + ->with( $this->identicalTo( $data ) ) + ->will( $this->returnValue( false ) ); + + $req2 = $mb->getMockForAbstractClass(); + $req2->expects( $this->once() )->method( 'loadFromSubmission' ) + ->with( $this->identicalTo( $data ) ) + ->will( $this->returnValue( true ) ); + + $this->assertSame( + [ $req2 ], + AuthenticationRequest::loadRequestsFromSubmission( [ $req1, $req2 ], $data ) + ); + } + + public function testGetRequestByClass() { + $mb = $this->getMockBuilder( + AuthenticationRequest::class, 'AuthenticationRequestTest_AuthenticationRequest2' + ); + + $reqs = [ + $this->getMockForAbstractClass( + AuthenticationRequest::class, [], 'AuthenticationRequestTest_AuthenticationRequest1' + ), + $mb->getMockForAbstractClass(), + $mb->getMockForAbstractClass(), + $this->getMockForAbstractClass( + PasswordAuthenticationRequest::class, [], + 'AuthenticationRequestTest_PasswordAuthenticationRequest' + ), + ]; + + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, 'AuthenticationRequestTest_AuthenticationRequest0' + ) ); + $this->assertSame( $reqs[0], AuthenticationRequest::getRequestByClass( + $reqs, 'AuthenticationRequestTest_AuthenticationRequest1' + ) ); + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, 'AuthenticationRequestTest_AuthenticationRequest2' + ) ); + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, PasswordAuthenticationRequest::class + ) ); + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, 'ClassThatDoesNotExist' + ) ); + + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, 'AuthenticationRequestTest_AuthenticationRequest0', true + ) ); + $this->assertSame( $reqs[0], AuthenticationRequest::getRequestByClass( + $reqs, 'AuthenticationRequestTest_AuthenticationRequest1', true + ) ); + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, 'AuthenticationRequestTest_AuthenticationRequest2', true + ) ); + $this->assertSame( $reqs[3], AuthenticationRequest::getRequestByClass( + $reqs, PasswordAuthenticationRequest::class, true + ) ); + $this->assertNull( AuthenticationRequest::getRequestByClass( + $reqs, 'ClassThatDoesNotExist', true + ) ); + } + + public function testGetUsernameFromRequests() { + $mb = $this->getMockBuilder( AuthenticationRequest::class ); + + for ( $i = 0; $i < 3; $i++ ) { + $req = $mb->getMockForAbstractClass(); + $req->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [ + 'username' => [ + 'type' => 'string', + ], + ] ) ); + $reqs[] = $req; + } + + $req = $mb->getMockForAbstractClass(); + $req->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [] ) ); + $req->username = 'baz'; + $reqs[] = $req; + + $this->assertNull( AuthenticationRequest::getUsernameFromRequests( $reqs ) ); + + $reqs[1]->username = 'foo'; + $this->assertSame( 'foo', AuthenticationRequest::getUsernameFromRequests( $reqs ) ); + + $reqs[0]->username = 'foo'; + $reqs[2]->username = 'foo'; + $this->assertSame( 'foo', AuthenticationRequest::getUsernameFromRequests( $reqs ) ); + + $reqs[1]->username = 'bar'; + try { + AuthenticationRequest::getUsernameFromRequests( $reqs ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'Conflicting username fields: "bar" from ' . + get_class( $reqs[1] ) . '::$username vs. "foo" from ' . + get_class( $reqs[0] ) . '::$username', + $ex->getMessage() + ); + } + } + + public function testMergeFieldInfo() { + $msg = wfMessage( 'foo' ); + + $req1 = $this->getMock( AuthenticationRequest::class ); + $req1->required = AuthenticationRequest::REQUIRED; + $req1->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [ + 'string1' => [ + 'type' => 'string', + 'label' => $msg, + 'help' => $msg, + ], + 'string2' => [ + 'type' => 'string', + 'label' => $msg, + 'help' => $msg, + ], + 'optional' => [ + 'type' => 'string', + 'label' => $msg, + 'help' => $msg, + 'optional' => true, + ], + 'select' => [ + 'type' => 'select', + 'options' => [ 'foo' => $msg, 'baz' => $msg ], + 'label' => $msg, + 'help' => $msg, + ], + ] ) ); + + $req2 = $this->getMock( AuthenticationRequest::class ); + $req2->required = AuthenticationRequest::REQUIRED; + $req2->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [ + 'string1' => [ + 'type' => 'string', + 'label' => $msg, + 'help' => $msg, + 'sensitive' => true, + ], + 'string3' => [ + 'type' => 'string', + 'label' => $msg, + 'help' => $msg, + ], + 'select' => [ + 'type' => 'select', + 'options' => [ 'bar' => $msg, 'baz' => $msg ], + 'label' => $msg, + 'help' => $msg, + ], + ] ) ); + + $req3 = $this->getMock( AuthenticationRequest::class ); + $req3->required = AuthenticationRequest::REQUIRED; + $req3->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [ + 'string1' => [ + 'type' => 'checkbox', + 'label' => $msg, + 'help' => $msg, + ], + ] ) ); + + $req4 = $this->getMock( AuthenticationRequest::class ); + $req4->required = AuthenticationRequest::REQUIRED; + $req4->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [] ) ); + + // Basic combining + + $fields = AuthenticationRequest::mergeFieldInfo( [ $req1 ] ); + $expect = $req1->getFieldInfo(); + foreach ( $expect as $name => &$options ) { + $options['optional'] = !empty( $options['optional'] ); + $options['sensitive'] = !empty( $options['sensitive'] ); + } + unset( $options ); + $this->assertEquals( $expect, $fields ); + + $fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req4 ] ); + $this->assertEquals( $expect, $fields ); + + try { + AuthenticationRequest::mergeFieldInfo( [ $req1, $req3 ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'Field type conflict for "string1", "string" vs "checkbox"', + $ex->getMessage() + ); + } + + $fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req2 ] ); + $expect += $req2->getFieldInfo(); + $expect['string1']['sensitive'] = true; + $expect['string2']['optional'] = false; + $expect['string3']['optional'] = false; + $expect['string3']['sensitive'] = false; + $expect['select']['options']['bar'] = $msg; + $this->assertEquals( $expect, $fields ); + + // Combining with something not required + + $req1->required = AuthenticationRequest::PRIMARY_REQUIRED; + + $fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req2 ] ); + $expect += $req2->getFieldInfo(); + $expect['string1']['optional'] = false; + $expect['string1']['sensitive'] = true; + $expect['string3']['optional'] = false; + $expect['select']['optional'] = false; + $expect['select']['options']['bar'] = $msg; + $this->assertEquals( $expect, $fields ); + + $req2->required = AuthenticationRequest::PRIMARY_REQUIRED; + + $fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req2 ] ); + $expect = $req1->getFieldInfo() + $req2->getFieldInfo(); + foreach ( $expect as $name => &$options ) { + $options['sensitive'] = !empty( $options['sensitive'] ); + } + $expect['string1']['optional'] = false; + $expect['string1']['sensitive'] = true; + $expect['string2']['optional'] = true; + $expect['string3']['optional'] = true; + $expect['select']['optional'] = false; + $expect['select']['options']['bar'] = $msg; + $this->assertEquals( $expect, $fields ); + } + + /** + * @dataProvider provideLoadFromSubmission + * @param array $fieldInfo + * @param array $data + * @param array|bool $expectState + */ + public function testLoadFromSubmission( $fieldInfo, $data, $expectState ) { + $mock = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $mock->expects( $this->any() )->method( 'getFieldInfo' ) + ->will( $this->returnValue( $fieldInfo ) ); + + $ret = $mock->loadFromSubmission( $data ); + if ( is_array( $expectState ) ) { + $this->assertTrue( $ret ); + $expect = call_user_func( [ get_class( $mock ), '__set_state' ], $expectState ); + $this->assertEquals( $expect, $mock ); + } else { + $this->assertFalse( $ret ); + } + } + + public static function provideLoadFromSubmission() { + return [ + 'No fields' => [ + [], + $data = [ 'foo' => 'bar' ], + false + ], + + 'Simple field' => [ + [ + 'field' => [ + 'type' => 'string', + ], + ], + $data = [ 'field' => 'string!' ], + $data + ], + 'Simple field, not supplied' => [ + [ + 'field' => [ + 'type' => 'string', + ], + ], + [], + false + ], + 'Simple field, empty' => [ + [ + 'field' => [ + 'type' => 'string', + ], + ], + [ 'field' => '' ], + false + ], + 'Simple field, optional, not supplied' => [ + [ + 'field' => [ + 'type' => 'string', + 'optional' => true, + ], + ], + [], + false + ], + 'Simple field, optional, empty' => [ + [ + 'field' => [ + 'type' => 'string', + 'optional' => true, + ], + ], + $data = [ 'field' => '' ], + $data + ], + + 'Checkbox, checked' => [ + [ + 'check' => [ + 'type' => 'checkbox', + ], + ], + [ 'check' => '' ], + [ 'check' => true ] + ], + 'Checkbox, unchecked' => [ + [ + 'check' => [ + 'type' => 'checkbox', + ], + ], + [], + false + ], + 'Checkbox, optional, unchecked' => [ + [ + 'check' => [ + 'type' => 'checkbox', + 'optional' => true, + ], + ], + [], + [ 'check' => false ] + ], + + 'Button, used' => [ + [ + 'push' => [ + 'type' => 'button', + ], + ], + [ 'push' => '' ], + [ 'push' => true ] + ], + 'Button, unused' => [ + [ + 'push' => [ + 'type' => 'button', + ], + ], + [], + false + ], + 'Button, optional, unused' => [ + [ + 'push' => [ + 'type' => 'button', + 'optional' => true, + ], + ], + [], + [ 'push' => false ] + ], + 'Button, image-style' => [ + [ + 'push' => [ + 'type' => 'button', + ], + ], + [ 'push_x' => 0, 'push_y' => 0 ], + [ 'push' => true ] + ], + + 'Select' => [ + [ + 'choose' => [ + 'type' => 'select', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + $data = [ 'choose' => 'foo' ], + $data + ], + 'Select, invalid choice' => [ + [ + 'choose' => [ + 'type' => 'select', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + $data = [ 'choose' => 'baz' ], + false + ], + 'Multiselect (2)' => [ + [ + 'choose' => [ + 'type' => 'multiselect', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + $data = [ 'choose' => [ 'foo', 'bar' ] ], + $data + ], + 'Multiselect (1)' => [ + [ + 'choose' => [ + 'type' => 'multiselect', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + $data = [ 'choose' => [ 'bar' ] ], + $data + ], + 'Multiselect, string for some reason' => [ + [ + 'choose' => [ + 'type' => 'multiselect', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + [ 'choose' => 'foo' ], + [ 'choose' => [ 'foo' ] ] + ], + 'Multiselect, invalid choice' => [ + [ + 'choose' => [ + 'type' => 'multiselect', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + [ 'choose' => [ 'foo', 'baz' ] ], + false + ], + 'Multiselect, empty' => [ + [ + 'choose' => [ + 'type' => 'multiselect', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + ], + ], + [ 'choose' => [] ], + false + ], + 'Multiselect, optional, nothing submitted' => [ + [ + 'choose' => [ + 'type' => 'multiselect', + 'options' => [ + 'foo' => wfMessage( 'mainpage' ), + 'bar' => wfMessage( 'mainpage' ), + ], + 'optional' => true, + ], + ], + [], + [ 'choose' => [] ] + ], + ]; + } +} diff --git a/tests/phpunit/includes/auth/AuthenticationRequestTestCase.php b/tests/phpunit/includes/auth/AuthenticationRequestTestCase.php new file mode 100644 index 0000000000..b5c8a36cd1 --- /dev/null +++ b/tests/phpunit/includes/auth/AuthenticationRequestTestCase.php @@ -0,0 +1,94 @@ +getInstance( $args )->getFieldInfo(); + $this->assertType( 'array', $info ); + + foreach ( $info as $field => $data ) { + $this->assertType( 'array', $data, "Field $field" ); + $this->assertArrayHasKey( 'type', $data, "Field $field" ); + $this->assertArrayHasKey( 'label', $data, "Field $field" ); + $this->assertInstanceOf( 'Message', $data['label'], "Field $field, label" ); + + if ( $data['type'] !== 'null' ) { + $this->assertArrayHasKey( 'help', $data, "Field $field" ); + $this->assertInstanceOf( 'Message', $data['help'], "Field $field, help" ); + } + + if ( isset( $data['optional'] ) ) { + $this->assertType( 'bool', $data['optional'], "Field $field, optional" ); + } + if ( isset( $data['image'] ) ) { + $this->assertType( 'string', $data['image'], "Field $field, image" ); + } + if ( isset( $data['sensitive'] ) ) { + $this->assertType( 'bool', $data['sensitive'], "Field $field, sensitive" ); + } + if ( $data['type'] === 'password' ) { + $this->assertTrue( !empty( $data['sensitive'] ), + "Field $field, password field must be sensitive" ); + } + + switch ( $data['type'] ) { + case 'string': + case 'password': + case 'hidden': + break; + case 'select': + case 'multiselect': + $this->assertArrayHasKey( 'options', $data, "Field $field" ); + $this->assertType( 'array', $data['options'], "Field $field, options" ); + foreach ( $data['options'] as $val => $msg ) { + $this->assertInstanceOf( 'Message', $msg, "Field $field, option $val" ); + } + break; + case 'checkbox': + break; + case 'button': + break; + case 'null': + break; + default: + $this->fail( "Field $field, unknown type " . $data['type'] ); + break; + } + } + } + + public static function provideGetFieldInfo() { + return [ + [ [] ] + ]; + } + + /** + * @dataProvider provideLoadFromSubmission + * @param array $args + * @param array $data + * @param array|bool $expectState + */ + public function testLoadFromSubmission( array $args, array $data, $expectState ) { + $instance = $this->getInstance( $args ); + $ret = $instance->loadFromSubmission( $data ); + if ( is_array( $expectState ) ) { + $this->assertTrue( $ret ); + $expect = call_user_func( [ get_class( $instance ), '__set_state' ], $expectState ); + $this->assertEquals( $expect, $instance ); + } else { + $this->assertFalse( $ret ); + } + } + + abstract public function provideLoadFromSubmission(); +} diff --git a/tests/phpunit/includes/auth/AuthenticationResponseTest.php b/tests/phpunit/includes/auth/AuthenticationResponseTest.php new file mode 100644 index 0000000000..194b49e01a --- /dev/null +++ b/tests/phpunit/includes/auth/AuthenticationResponseTest.php @@ -0,0 +1,112 @@ +messageType = 'warning'; + foreach ( $expect as $field => $value ) { + $res->$field = $value; + } + $ret = call_user_func_array( "MediaWiki\\Auth\\AuthenticationResponse::$constructor", $args ); + $this->assertEquals( $res, $ret ); + } else { + try { + call_user_func_array( "MediaWiki\\Auth\\AuthenticationResponse::$constructor", $args ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \Exception $ex ) { + $this->assertEquals( $expect, $ex ); + } + } + } + + public function provideConstructors() { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $msg = new \Message( 'mainpage' ); + + return [ + [ 'newPass', [], [ + 'status' => AuthenticationResponse::PASS, + ] ], + [ 'newPass', [ 'name' ], [ + 'status' => AuthenticationResponse::PASS, + 'username' => 'name', + ] ], + [ 'newPass', [ 'name', null ], [ + 'status' => AuthenticationResponse::PASS, + 'username' => 'name', + ] ], + + [ 'newFail', [ $msg ], [ + 'status' => AuthenticationResponse::FAIL, + 'message' => $msg, + 'messageType' => 'error', + ] ], + + [ 'newRestart', [ $msg ], [ + 'status' => AuthenticationResponse::RESTART, + 'message' => $msg, + ] ], + + [ 'newAbstain', [], [ + 'status' => AuthenticationResponse::ABSTAIN, + ] ], + + [ 'newUI', [ [ $req ], $msg ], [ + 'status' => AuthenticationResponse::UI, + 'neededRequests' => [ $req ], + 'message' => $msg, + 'messageType' => 'warning', + ] ], + + [ 'newUI', [ [ $req ], $msg, 'warning' ], [ + 'status' => AuthenticationResponse::UI, + 'neededRequests' => [ $req ], + 'message' => $msg, + 'messageType' => 'warning', + ] ], + + [ 'newUI', [ [ $req ], $msg, 'error' ], [ + 'status' => AuthenticationResponse::UI, + 'neededRequests' => [ $req ], + 'message' => $msg, + 'messageType' => 'error', + ] ], + [ 'newUI', [ [], $msg ], + new \InvalidArgumentException( '$reqs may not be empty' ) + ], + + [ 'newRedirect', [ [ $req ], 'http://example.org/redir' ], [ + 'status' => AuthenticationResponse::REDIRECT, + 'neededRequests' => [ $req ], + 'redirectTarget' => 'http://example.org/redir', + ] ], + [ + 'newRedirect', + [ [ $req ], 'http://example.org/redir', [ 'foo' => 'bar' ] ], + [ + 'status' => AuthenticationResponse::REDIRECT, + 'neededRequests' => [ $req ], + 'redirectTarget' => 'http://example.org/redir', + 'redirectApiData' => [ 'foo' => 'bar' ], + ] + ], + [ 'newRedirect', [ [], 'http://example.org/redir' ], + new \InvalidArgumentException( '$reqs may not be empty' ) + ], + ]; + } + +} diff --git a/tests/phpunit/includes/auth/ButtonAuthenticationRequestTest.php b/tests/phpunit/includes/auth/ButtonAuthenticationRequestTest.php new file mode 100644 index 0000000000..3bc077cb76 --- /dev/null +++ b/tests/phpunit/includes/auth/ButtonAuthenticationRequestTest.php @@ -0,0 +1,64 @@ + 1, 'label' => 1, 'help' => 1 ] ); + return ButtonAuthenticationRequest::__set_state( $data ); + } + + public static function provideGetFieldInfo() { + return [ + [ [ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz' ] ] + ]; + } + + public function provideLoadFromSubmission() { + return [ + 'Empty request' => [ + [ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz' ], + [], + false + ], + 'Button present' => [ + [ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz' ], + [ 'foo' => 'Foobar' ], + [ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz', 'foo' => true ] + ], + ]; + } + + public function testGetUniqueId() { + $req = new ButtonAuthenticationRequest( 'foo', wfMessage( 'bar' ), wfMessage( 'baz' ) ); + $this->assertSame( + 'MediaWiki\\Auth\\ButtonAuthenticationRequest:foo', $req->getUniqueId() + ); + } + + public function testGetRequestByName() { + $reqs = []; + $reqs['testOne'] = new ButtonAuthenticationRequest( + 'foo', wfMessage( 'msg' ), wfMessage( 'help' ) + ); + $reqs[] = new ButtonAuthenticationRequest( 'bar', wfMessage( 'msg1' ), wfMessage( 'help1' ) ); + $reqs[] = new ButtonAuthenticationRequest( 'bar', wfMessage( 'msg2' ), wfMessage( 'help2' ) ); + $reqs['testSub'] = $this->getMockBuilder( ButtonAuthenticationRequest::class ) + ->setConstructorArgs( [ 'subclass', wfMessage( 'msg3' ), wfMessage( 'help3' ) ] ) + ->getMock(); + + $this->assertNull( ButtonAuthenticationRequest::getRequestByName( $reqs, 'missing' ) ); + $this->assertSame( + $reqs['testOne'], ButtonAuthenticationRequest::getRequestByName( $reqs, 'foo' ) + ); + $this->assertNull( ButtonAuthenticationRequest::getRequestByName( $reqs, 'bar' ) ); + $this->assertSame( + $reqs['testSub'], ButtonAuthenticationRequest::getRequestByName( $reqs, 'subclass' ) + ); + } +} diff --git a/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..68f574b6b4 --- /dev/null +++ b/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php @@ -0,0 +1,186 @@ + false + ] ); + $provider->setConfig( $config ); + $this->assertSame( false, $providerPriv->blockDisablesLogin ); + + $provider = new CheckBlocksSecondaryAuthenticationProvider( + [ 'blockDisablesLogin' => true ] + ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + $config = new \HashConfig( [ + 'BlockDisablesLogin' => false + ] ); + $provider->setConfig( $config ); + $this->assertSame( true, $providerPriv->blockDisablesLogin ); + } + + public function testBasics() { + $provider = new CheckBlocksSecondaryAuthenticationProvider(); + $user = \User::newFromName( 'UTSysop' ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginSecondaryAccountCreation( $user, $user, [] ) + ); + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $response + */ + public function testGetAuthenticationRequests( $action, $response ) { + $provider = new CheckBlocksSecondaryAuthenticationProvider(); + + $this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public static function provideGetAuthenticationRequests() { + return [ + [ AuthManager::ACTION_LOGIN, [] ], + [ AuthManager::ACTION_CREATE, [] ], + [ AuthManager::ACTION_LINK, [] ], + [ AuthManager::ACTION_CHANGE, [] ], + [ AuthManager::ACTION_REMOVE, [] ], + ]; + } + + private function getBlockedUser() { + $user = \User::newFromName( 'UTBlockee' ); + if ( $user->getID() == 0 ) { + $user->addToDatabase(); + \TestUser::setPasswordForUser( $user, 'UTBlockeePassword' ); + $user->saveSettings(); + } + $oldBlock = \Block::newFromTarget( 'UTBlockee' ); + if ( $oldBlock ) { + // An old block will prevent our new one from saving. + $oldBlock->delete(); + } + $blockOptions = [ + 'address' => 'UTBlockee', + 'user' => $user->getID(), + 'reason' => __METHOD__, + 'expiry' => time() + 100500, + 'createAccount' => true, + ]; + $block = new \Block( $blockOptions ); + $block->insert(); + return $user; + } + + public function testBeginSecondaryAuthentication() { + $unblockedUser = \User::newFromName( 'UTSysop' ); + $blockedUser = $this->getBlockedUser(); + + $provider = new CheckBlocksSecondaryAuthenticationProvider( + [ 'blockDisablesLogin' => false ] + ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginSecondaryAuthentication( $unblockedUser, [] ) + ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginSecondaryAuthentication( $blockedUser, [] ) + ); + + $provider = new CheckBlocksSecondaryAuthenticationProvider( + [ 'blockDisablesLogin' => true ] + ); + $this->assertEquals( + AuthenticationResponse::newPass(), + $provider->beginSecondaryAuthentication( $unblockedUser, [] ) + ); + $ret = $provider->beginSecondaryAuthentication( $blockedUser, [] ); + $this->assertEquals( AuthenticationResponse::FAIL, $ret->status ); + } + + public function testTestUserForCreation() { + $provider = new CheckBlocksSecondaryAuthenticationProvider( + [ 'blockDisablesLogin' => false ] + ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( new \HashConfig() ); + $provider->setManager( AuthManager::singleton() ); + + $unblockedUser = \User::newFromName( 'UTSysop' ); + $blockedUser = $this->getBlockedUser(); + + $user = \User::newFromName( 'RandomUser' ); + + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $unblockedUser, AuthManager::AUTOCREATE_SOURCE_SESSION ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $unblockedUser, false ) + ); + + $status = $provider->testUserForCreation( $blockedUser, AuthManager::AUTOCREATE_SOURCE_SESSION ); + $this->assertInstanceOf( 'StatusValue', $status ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'cantcreateaccount-text' ) ); + + $status = $provider->testUserForCreation( $blockedUser, false ); + $this->assertInstanceOf( 'StatusValue', $status ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'cantcreateaccount-text' ) ); + } + + public function testRangeBlock() { + $blockOptions = [ + 'address' => '127.0.0.0/24', + 'reason' => __METHOD__, + 'expiry' => time() + 100500, + 'createAccount' => true, + ]; + $block = new \Block( $blockOptions ); + $block->insert(); + $scopeVariable = new \Wikimedia\ScopedCallback( [ $block, 'delete' ] ); + + $user = \User::newFromName( 'UTNormalUser' ); + if ( $user->getID() == 0 ) { + $user->addToDatabase(); + \TestUser::setPasswordForUser( $user, 'UTNormalUserPassword' ); + $user->saveSettings(); + } + $this->setMwGlobals( [ 'wgUser' => $user ] ); + $newuser = \User::newFromName( 'RandomUser' ); + + $provider = new CheckBlocksSecondaryAuthenticationProvider( + [ 'blockDisablesLogin' => true ] + ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( new \HashConfig() ); + $provider->setManager( AuthManager::singleton() ); + + $ret = $provider->beginSecondaryAuthentication( $user, [] ); + $this->assertEquals( AuthenticationResponse::FAIL, $ret->status ); + + $status = $provider->testUserForCreation( $newuser, AuthManager::AUTOCREATE_SOURCE_SESSION ); + $this->assertInstanceOf( 'StatusValue', $status ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'cantcreateaccount-range-text' ) ); + + $status = $provider->testUserForCreation( $newuser, false ); + $this->assertInstanceOf( 'StatusValue', $status ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( 'cantcreateaccount-range-text' ) ); + } +} diff --git a/tests/phpunit/includes/auth/ConfirmLinkAuthenticationRequestTest.php b/tests/phpunit/includes/auth/ConfirmLinkAuthenticationRequestTest.php new file mode 100644 index 0000000000..f208cc4be7 --- /dev/null +++ b/tests/phpunit/includes/auth/ConfirmLinkAuthenticationRequestTest.php @@ -0,0 +1,68 @@ +getLinkRequests() ); + } + + /** + * @expectedException InvalidArgumentException + * @expectedExceptionMessage $linkRequests must not be empty + */ + public function testConstructorException() { + new ConfirmLinkAuthenticationRequest( [] ); + } + + /** + * Get requests for testing + * @return AuthenticationRequest[] + */ + private function getLinkRequests() { + $reqs = []; + + $mb = $this->getMockBuilder( AuthenticationRequest::class ) + ->setMethods( [ 'getUniqueId' ] ); + for ( $i = 1; $i <= 3; $i++ ) { + $req = $mb->getMockForAbstractClass(); + $req->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( "Request$i" ) ); + $reqs[$req->getUniqueId()] = $req; + } + + return $reqs; + } + + public function provideLoadFromSubmission() { + $reqs = $this->getLinkRequests(); + + return [ + 'Empty request' => [ + [], + [], + [ 'linkRequests' => $reqs ], + ], + 'Some confirmed' => [ + [], + [ 'confirmedLinkIDs' => [ 'Request1', 'Request3' ] ], + [ 'confirmedLinkIDs' => [ 'Request1', 'Request3' ], 'linkRequests' => $reqs ], + ], + ]; + } + + public function testGetUniqueId() { + $req = new ConfirmLinkAuthenticationRequest( $this->getLinkRequests() ); + $this->assertSame( + get_class( $req ) . ':Request1|Request2|Request3', + $req->getUniqueId() + ); + } +} diff --git a/tests/phpunit/includes/auth/ConfirmLinkSecondaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/ConfirmLinkSecondaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..3fc45a4671 --- /dev/null +++ b/tests/phpunit/includes/auth/ConfirmLinkSecondaryAuthenticationProviderTest.php @@ -0,0 +1,287 @@ +assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public static function provideGetAuthenticationRequests() { + return [ + [ AuthManager::ACTION_LOGIN, [] ], + [ AuthManager::ACTION_CREATE, [] ], + [ AuthManager::ACTION_LINK, [] ], + [ AuthManager::ACTION_CHANGE, [] ], + [ AuthManager::ACTION_REMOVE, [] ], + ]; + } + + public function testBeginSecondaryAuthentication() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->once() )->method( 'beginLinkAttempt' ) + ->with( $this->identicalTo( $user ), $this->identicalTo( 'AuthManager::authnState' ) ) + ->will( $this->returnValue( $obj ) ); + $mock->expects( $this->never() )->method( 'continueLinkAttempt' ); + + $this->assertSame( $obj, $mock->beginSecondaryAuthentication( $user, [] ) ); + } + + public function testContinueSecondaryAuthentication() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + $reqs = [ new \stdClass ]; + + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->never() )->method( 'beginLinkAttempt' ); + $mock->expects( $this->once() )->method( 'continueLinkAttempt' ) + ->with( + $this->identicalTo( $user ), + $this->identicalTo( 'AuthManager::authnState' ), + $this->identicalTo( $reqs ) + ) + ->will( $this->returnValue( $obj ) ); + + $this->assertSame( $obj, $mock->continueSecondaryAuthentication( $user, $reqs ) ); + } + + public function testBeginSecondaryAccountCreation() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->once() )->method( 'beginLinkAttempt' ) + ->with( $this->identicalTo( $user ), $this->identicalTo( 'AuthManager::accountCreationState' ) ) + ->will( $this->returnValue( $obj ) ); + $mock->expects( $this->never() )->method( 'continueLinkAttempt' ); + + $this->assertSame( $obj, $mock->beginSecondaryAccountCreation( $user, $user, [] ) ); + } + + public function testContinueSecondaryAccountCreation() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + $reqs = [ new \stdClass ]; + + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->never() )->method( 'beginLinkAttempt' ); + $mock->expects( $this->once() )->method( 'continueLinkAttempt' ) + ->with( + $this->identicalTo( $user ), + $this->identicalTo( 'AuthManager::accountCreationState' ), + $this->identicalTo( $reqs ) + ) + ->will( $this->returnValue( $obj ) ); + + $this->assertSame( $obj, $mock->continueSecondaryAccountCreation( $user, $user, $reqs ) ); + } + + /** + * Get requests for testing + * @return AuthenticationRequest[] + */ + private function getLinkRequests() { + $reqs = []; + + $mb = $this->getMockBuilder( AuthenticationRequest::class ) + ->setMethods( [ 'getUniqueId' ] ); + for ( $i = 1; $i <= 3; $i++ ) { + $req = $mb->getMockForAbstractClass(); + $req->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( "Request$i" ) ); + $req->id = $i - 1; + $reqs[$req->getUniqueId()] = $req; + } + + return $reqs; + } + + public function testBeginLinkAttempt() { + $badReq = $this->getMockBuilder( AuthenticationRequest::class ) + ->setMethods( [ 'getUniqueId' ] ) + ->getMockForAbstractClass(); + $badReq->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( "BadReq" ) ); + + $user = \User::newFromName( 'UTSysop' ); + $provider = \TestingAccessWrapper::newFromObject( + new ConfirmLinkSecondaryAuthenticationProvider + ); + $request = new \FauxRequest(); + $manager = $this->getMockBuilder( AuthManager::class ) + ->setMethods( [ 'allowsAuthenticationDataChange' ] ) + ->setConstructorArgs( [ $request, \RequestContext::getMain()->getConfig() ] ) + ->getMock(); + $manager->expects( $this->any() )->method( 'allowsAuthenticationDataChange' ) + ->will( $this->returnCallback( function ( $req ) { + return $req->getUniqueId() !== 'BadReq' + ? \StatusValue::newGood() + : \StatusValue::newFatal( 'no' ); + } ) ); + $provider->setManager( $manager ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginLinkAttempt( $user, 'state' ) + ); + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => [], + ] ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginLinkAttempt( $user, 'state' ) + ); + + $reqs = $this->getLinkRequests(); + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => $reqs + [ 'BadReq' => $badReq ] + ] ); + $res = $provider->beginLinkAttempt( $user, 'state' ); + $this->assertInstanceOf( AuthenticationResponse::class, $res ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertSame( 'authprovider-confirmlink-message', $res->message->getKey() ); + $this->assertCount( 1, $res->neededRequests ); + $req = $res->neededRequests[0]; + $this->assertInstanceOf( ConfirmLinkAuthenticationRequest::class, $req ); + $expectReqs = $this->getLinkRequests(); + foreach ( $expectReqs as $r ) { + $r->action = AuthManager::ACTION_CHANGE; + $r->username = $user->getName(); + } + $this->assertEquals( $expectReqs, \TestingAccessWrapper::newFromObject( $req )->linkRequests ); + } + + public function testContinueLinkAttempt() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + $reqs = $this->getLinkRequests(); + + $done = [ false, false, false ]; + + // First, test the pass-through for not containing the ConfirmLinkAuthenticationRequest + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->once() )->method( 'beginLinkAttempt' ) + ->with( $this->identicalTo( $user ), $this->identicalTo( 'state' ) ) + ->will( $this->returnValue( $obj ) ); + $this->assertSame( + $obj, + \TestingAccessWrapper::newFromObject( $mock )->continueLinkAttempt( $user, 'state', $reqs ) + ); + + // Now test the actual functioning + $provider = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ + 'beginLinkAttempt', 'providerAllowsAuthenticationDataChange', + 'providerChangeAuthenticationData' + ] ) + ->getMock(); + $provider->expects( $this->never() )->method( 'beginLinkAttempt' ); + $provider->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' ) + ->will( $this->returnCallback( function ( $req ) use ( $reqs ) { + return $req->getUniqueId() === 'Request3' + ? \StatusValue::newFatal( 'foo' ) : \StatusValue::newGood(); + } ) ); + $provider->expects( $this->any() )->method( 'providerChangeAuthenticationData' ) + ->will( $this->returnCallback( function ( $req ) use ( &$done ) { + $done[$req->id] = true; + } ) ); + $config = new \HashConfig( [ + 'AuthManagerConfig' => [ + 'preauth' => [], + 'primaryauth' => [], + 'secondaryauth' => [ + [ 'factory' => function () use ( $provider ) { + return $provider; + } ], + ], + ], + ] ); + $request = new \FauxRequest(); + $manager = new AuthManager( $request, $config ); + $provider->setManager( $manager ); + $provider = \TestingAccessWrapper::newFromObject( $provider ); + + $req = new ConfirmLinkAuthenticationRequest( $reqs ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->continueLinkAttempt( $user, 'state', [ $req ] ) + ); + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => [], + ] ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->continueLinkAttempt( $user, 'state', [ $req ] ) + ); + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => $reqs + ] ); + $this->assertEquals( + AuthenticationResponse::newPass(), + $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] ) + ); + $this->assertSame( [ false, false, false ], $done ); + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => [ $reqs['Request2'] ], + ] ); + $req->confirmedLinkIDs = [ 'Request1', 'Request2' ]; + $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertSame( [ false, true, false ], $done ); + $done = [ false, false, false ]; + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => $reqs, + ] ); + $req->confirmedLinkIDs = [ 'Request1', 'Request2' ]; + $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertSame( [ true, true, false ], $done ); + $done = [ false, false, false ]; + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => $reqs, + ] ); + $req->confirmedLinkIDs = [ 'Request1', 'Request3' ]; + $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] ); + $this->assertEquals( AuthenticationResponse::UI, $res->status ); + $this->assertCount( 1, $res->neededRequests ); + $this->assertInstanceOf( ButtonAuthenticationRequest::class, $res->neededRequests[0] ); + $this->assertSame( [ true, false, false ], $done ); + $done = [ false, false, false ]; + + $res = $provider->continueLinkAttempt( $user, 'state', [ $res->neededRequests[0] ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertSame( [ false, false, false ], $done ); + } + +} diff --git a/tests/phpunit/includes/auth/CreateFromLoginAuthenticationRequestTest.php b/tests/phpunit/includes/auth/CreateFromLoginAuthenticationRequestTest.php new file mode 100644 index 0000000000..d166caa64e --- /dev/null +++ b/tests/phpunit/includes/auth/CreateFromLoginAuthenticationRequestTest.php @@ -0,0 +1,57 @@ + [ + [], + [], + [], + ], + ]; + } + + /** + * @dataProvider provideState + */ + public function testState( + $createReq, $maybeLink, $username, $loginState, $createState, $createPrimaryState + ) { + $req = new CreateFromLoginAuthenticationRequest( $createReq, $maybeLink ); + $this->assertSame( $username, $req->username ); + $this->assertSame( $loginState, $req->hasStateForAction( AuthManager::ACTION_LOGIN ) ); + $this->assertSame( $createState, $req->hasStateForAction( AuthManager::ACTION_CREATE ) ); + $this->assertFalse( $req->hasStateForAction( AuthManager::ACTION_LINK ) ); + $this->assertFalse( $req->hasPrimaryStateForAction( AuthManager::ACTION_LOGIN ) ); + $this->assertSame( $createPrimaryState, + $req->hasPrimaryStateForAction( AuthManager::ACTION_CREATE ) ); + } + + public static function provideState() { + $req1 = new UsernameAuthenticationRequest; + $req2 = new UsernameAuthenticationRequest; + $req2->username = 'Bob'; + + return [ + 'Nothing' => [ null, [], null, false, false, false ], + 'Link, no create' => [ null, [ $req2 ], null, true, true, false ], + 'No link, create but no name' => [ $req1, [], null, false, true, true ], + 'Link and create but no name' => [ $req1, [ $req2 ], null, true, true, true ], + 'No link, create with name' => [ $req2, [], 'Bob', false, true, true ], + 'Link and create with name' => [ $req2, [ $req2 ], 'Bob', true, true, true ], + ]; + } +} diff --git a/tests/phpunit/includes/auth/CreatedAccountAuthenticationRequestTest.php b/tests/phpunit/includes/auth/CreatedAccountAuthenticationRequestTest.php new file mode 100644 index 0000000000..fc1e6f15bb --- /dev/null +++ b/tests/phpunit/includes/auth/CreatedAccountAuthenticationRequestTest.php @@ -0,0 +1,30 @@ +assertSame( 42, $ret->id ); + $this->assertSame( 'Test', $ret->username ); + } + + public function provideLoadFromSubmission() { + return [ + 'Empty request' => [ + [], + [], + false + ], + ]; + } +} diff --git a/tests/phpunit/includes/auth/CreationReasonAuthenticationRequestTest.php b/tests/phpunit/includes/auth/CreationReasonAuthenticationRequestTest.php new file mode 100644 index 0000000000..cce1e8cdfb --- /dev/null +++ b/tests/phpunit/includes/auth/CreationReasonAuthenticationRequestTest.php @@ -0,0 +1,34 @@ + [ + [], + [], + false + ], + 'Reason given' => [ + [], + $data = [ 'reason' => 'Because' ], + $data, + ], + 'Reason empty' => [ + [], + [ 'reason' => '' ], + false + ], + ]; + } +} diff --git a/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..ec4bea11a1 --- /dev/null +++ b/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php @@ -0,0 +1,108 @@ + true, + 'EmailAuthentication' => true, + ] ); + + $provider = new EmailNotificationSecondaryAuthenticationProvider(); + $provider->setConfig( $config ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + $this->assertTrue( $providerPriv->sendConfirmationEmail ); + + $provider = new EmailNotificationSecondaryAuthenticationProvider( [ + 'sendConfirmationEmail' => false, + ] ); + $provider->setConfig( $config ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + $this->assertFalse( $providerPriv->sendConfirmationEmail ); + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param AuthenticationRequest[] $expected + */ + public function testGetAuthenticationRequests( $action, $expected ) { + $provider = new EmailNotificationSecondaryAuthenticationProvider( [ + 'sendConfirmationEmail' => true, + ] ); + $this->assertSame( $expected, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public function provideGetAuthenticationRequests() { + return [ + [ AuthManager::ACTION_LOGIN, [] ], + [ AuthManager::ACTION_CREATE, [] ], + [ AuthManager::ACTION_LINK, [] ], + [ AuthManager::ACTION_CHANGE, [] ], + [ AuthManager::ACTION_REMOVE, [] ], + ]; + } + + public function testBeginSecondaryAuthentication() { + $provider = new EmailNotificationSecondaryAuthenticationProvider( [ + 'sendConfirmationEmail' => true, + ] ); + $this->assertEquals( AuthenticationResponse::newAbstain(), + $provider->beginSecondaryAuthentication( \User::newFromName( 'Foo' ), [] ) ); + } + + public function testBeginSecondaryAccountCreation() { + $authManager = new AuthManager( new \FauxRequest(), new \HashConfig() ); + + $creator = $this->getMock( 'User' ); + $userWithoutEmail = $this->getMock( 'User' ); + $userWithoutEmail->expects( $this->any() )->method( 'getEmail' )->willReturn( '' ); + $userWithoutEmail->expects( $this->any() )->method( 'getInstanceForUpdate' )->willReturnSelf(); + $userWithoutEmail->expects( $this->never() )->method( 'sendConfirmationMail' ); + $userWithEmailError = $this->getMock( 'User' ); + $userWithEmailError->expects( $this->any() )->method( 'getEmail' )->willReturn( 'foo@bar.baz' ); + $userWithEmailError->expects( $this->any() )->method( 'getInstanceForUpdate' )->willReturnSelf(); + $userWithEmailError->expects( $this->any() )->method( 'sendConfirmationMail' ) + ->willReturn( \Status::newFatal( 'fail' ) ); + $userExpectsConfirmation = $this->getMock( 'User' ); + $userExpectsConfirmation->expects( $this->any() )->method( 'getEmail' ) + ->willReturn( 'foo@bar.baz' ); + $userExpectsConfirmation->expects( $this->any() )->method( 'getInstanceForUpdate' ) + ->willReturnSelf(); + $userExpectsConfirmation->expects( $this->once() )->method( 'sendConfirmationMail' ) + ->willReturn( \Status::newGood() ); + $userNotExpectsConfirmation = $this->getMock( 'User' ); + $userNotExpectsConfirmation->expects( $this->any() )->method( 'getEmail' ) + ->willReturn( 'foo@bar.baz' ); + $userNotExpectsConfirmation->expects( $this->any() )->method( 'getInstanceForUpdate' ) + ->willReturnSelf(); + $userNotExpectsConfirmation->expects( $this->never() )->method( 'sendConfirmationMail' ); + + $provider = new EmailNotificationSecondaryAuthenticationProvider( [ + 'sendConfirmationEmail' => false, + ] ); + $provider->setManager( $authManager ); + $provider->beginSecondaryAccountCreation( $userNotExpectsConfirmation, $creator, [] ); + + $provider = new EmailNotificationSecondaryAuthenticationProvider( [ + 'sendConfirmationEmail' => true, + ] ); + $provider->setManager( $authManager ); + $provider->beginSecondaryAccountCreation( $userWithoutEmail, $creator, [] ); + $provider->beginSecondaryAccountCreation( $userExpectsConfirmation, $creator, [] ); + + // test logging of email errors + $logger = $this->getMockForAbstractClass( LoggerInterface::class ); + $logger->expects( $this->once() )->method( 'warning' ); + $provider->setLogger( $logger ); + $provider->beginSecondaryAccountCreation( $userWithEmailError, $creator, [] ); + + // test disable flag used by other providers + $authManager->setAuthenticationSessionData( 'no-email', true ); + $provider->setManager( $authManager ); + $provider->beginSecondaryAccountCreation( $userNotExpectsConfirmation, $creator, [] ); + } +} diff --git a/tests/phpunit/includes/auth/LegacyHookPreAuthenticationProviderTest.php b/tests/phpunit/includes/auth/LegacyHookPreAuthenticationProviderTest.php new file mode 100644 index 0000000000..b96455e091 --- /dev/null +++ b/tests/phpunit/includes/auth/LegacyHookPreAuthenticationProviderTest.php @@ -0,0 +1,372 @@ +getMock( 'FauxRequest', [ 'getIP' ] ); + $request->expects( $this->any() )->method( 'getIP' )->will( $this->returnValue( '127.0.0.42' ) ); + + $manager = new AuthManager( + $request, + MediaWikiServices::getInstance()->getMainConfig() + ); + + $provider = new LegacyHookPreAuthenticationProvider(); + $provider->setManager( $manager ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( new \HashConfig( [ + 'PasswordAttemptThrottle' => [ 'count' => 23, 'seconds' => 42 ], + ] ) ); + return $provider; + } + + /** + * Sets a mock on a hook + * @param string $hook + * @param object $expect From $this->once(), $this->never(), etc. + * @return object $mock->expects( $expect )->method( ... ). + */ + protected function hook( $hook, $expect ) { + $mock = $this->getMock( __CLASS__, [ "on$hook" ] ); + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + $hook => [ $mock ], + ] ); + return $mock->expects( $expect )->method( "on$hook" ); + } + + /** + * Unsets a hook + * @param string $hook + */ + protected function unhook( $hook ) { + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + $hook => [], + ] ); + } + + // Stubs for hooks taking reference parameters + public function onLoginUserMigrated( $user, &$msg ) { + } + public function onAbortLogin( $user, $password, &$abort, &$msg ) { + } + public function onAbortNewAccount( $user, &$abortError, &$abortStatus ) { + } + public function onAbortAutoAccount( $user, &$abortError ) { + } + + /** + * @dataProvider provideTestForAuthentication + * @param string|null $username + * @param string|null $password + * @param string|null $msgForLoginUserMigrated + * @param int|null $abortForAbortLogin + * @param string|null $msgForAbortLogin + * @param string|null $failMsg + * @param array $failParams + */ + public function testTestForAuthentication( + $username, $password, + $msgForLoginUserMigrated, $abortForAbortLogin, $msgForAbortLogin, + $failMsg, $failParams = [] + ) { + $reqs = []; + if ( $username === null ) { + $this->hook( 'LoginUserMigrated', $this->never() ); + $this->hook( 'AbortLogin', $this->never() ); + } else { + if ( $password === null ) { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + } else { + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_LOGIN; + $req->password = $password; + } + $req->username = $username; + $reqs[get_class( $req )] = $req; + + $h = $this->hook( 'LoginUserMigrated', $this->once() ); + if ( $msgForLoginUserMigrated !== null ) { + $h->will( $this->returnCallback( + function ( $user, &$msg ) use ( $username, $msgForLoginUserMigrated ) { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( $username, $user->getName() ); + $msg = $msgForLoginUserMigrated; + return false; + } + ) ); + $this->hook( 'AbortLogin', $this->never() ); + } else { + $h->will( $this->returnCallback( + function ( $user, &$msg ) use ( $username ) { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( $username, $user->getName() ); + return true; + } + ) ); + $h2 = $this->hook( 'AbortLogin', $this->once() ); + if ( $abortForAbortLogin !== null ) { + $h2->will( $this->returnCallback( + function ( $user, $pass, &$abort, &$msg ) + use ( $username, $password, $abortForAbortLogin, $msgForAbortLogin ) + { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( $username, $user->getName() ); + if ( $password !== null ) { + $this->assertSame( $password, $pass ); + } else { + $this->assertInternalType( 'string', $pass ); + } + $abort = $abortForAbortLogin; + $msg = $msgForAbortLogin; + return false; + } + ) ); + } else { + $h2->will( $this->returnCallback( + function ( $user, $pass, &$abort, &$msg ) use ( $username, $password ) { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( $username, $user->getName() ); + if ( $password !== null ) { + $this->assertSame( $password, $pass ); + } else { + $this->assertInternalType( 'string', $pass ); + } + return true; + } + ) ); + } + } + } + unset( $h, $h2 ); + + $status = $this->getProvider()->testForAuthentication( $reqs ); + + $this->unhook( 'LoginUserMigrated' ); + $this->unhook( 'AbortLogin' ); + + if ( $failMsg === null ) { + $this->assertEquals( \StatusValue::newGood(), $status, 'should succeed' ); + } else { + $this->assertInstanceOf( 'StatusValue', $status, 'should fail (type)' ); + $this->assertFalse( $status->isOk(), 'should fail (ok)' ); + $errors = $status->getErrors(); + $this->assertEquals( $failMsg, $errors[0]['message'], 'should fail (message)' ); + $this->assertEquals( $failParams, $errors[0]['params'], 'should fail (params)' ); + } + } + + public static function provideTestForAuthentication() { + return [ + 'No valid requests' => [ + null, null, null, null, null, null + ], + 'No hook errors' => [ + 'User', 'PaSsWoRd', null, null, null, null + ], + 'No hook errors, no password' => [ + 'User', null, null, null, null, null + ], + 'LoginUserMigrated no message' => [ + 'User', 'PaSsWoRd', false, null, null, 'login-migrated-generic' + ], + 'LoginUserMigrated with message' => [ + 'User', 'PaSsWoRd', 'LUM-abort', null, null, 'LUM-abort' + ], + 'LoginUserMigrated with message and params' => [ + 'User', 'PaSsWoRd', [ 'LUM-abort', 'foo' ], null, null, 'LUM-abort', [ 'foo' ] + ], + 'AbortLogin, SUCCESS' => [ + 'User', 'PaSsWoRd', null, \LoginForm::SUCCESS, null, null + ], + 'AbortLogin, NEED_TOKEN, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::NEED_TOKEN, null, 'nocookiesforlogin' + ], + 'AbortLogin, NEED_TOKEN, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::NEED_TOKEN, 'needtoken', 'needtoken' + ], + 'AbortLogin, WRONG_TOKEN, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::WRONG_TOKEN, null, 'sessionfailure' + ], + 'AbortLogin, WRONG_TOKEN, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::WRONG_TOKEN, 'wrongtoken', 'wrongtoken' + ], + 'AbortLogin, ILLEGAL, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::ILLEGAL, null, 'noname' + ], + 'AbortLogin, ILLEGAL, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::ILLEGAL, 'badname', 'badname' + ], + 'AbortLogin, NO_NAME, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::NO_NAME, null, 'noname' + ], + 'AbortLogin, NO_NAME, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::NO_NAME, 'badname', 'badname' + ], + 'AbortLogin, WRONG_PASS, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::WRONG_PASS, null, 'wrongpassword' + ], + 'AbortLogin, WRONG_PASS, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::WRONG_PASS, 'badpass', 'badpass' + ], + 'AbortLogin, WRONG_PLUGIN_PASS, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::WRONG_PLUGIN_PASS, null, 'wrongpassword' + ], + 'AbortLogin, WRONG_PLUGIN_PASS, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::WRONG_PLUGIN_PASS, 'badpass', 'badpass' + ], + 'AbortLogin, NOT_EXISTS, no message' => [ + "User'", 'A', null, \LoginForm::NOT_EXISTS, null, 'nosuchusershort', [ 'User'' ] + ], + 'AbortLogin, NOT_EXISTS, with message' => [ + "User'", 'A', null, \LoginForm::NOT_EXISTS, 'badname', 'badname', [ 'User'' ] + ], + 'AbortLogin, EMPTY_PASS, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::EMPTY_PASS, null, 'wrongpasswordempty' + ], + 'AbortLogin, EMPTY_PASS, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::EMPTY_PASS, 'badpass', 'badpass' + ], + 'AbortLogin, RESET_PASS, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::RESET_PASS, null, 'resetpass_announce' + ], + 'AbortLogin, RESET_PASS, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::RESET_PASS, 'resetpass', 'resetpass' + ], + 'AbortLogin, THROTTLED, no message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::THROTTLED, null, 'login-throttled', + [ \Message::durationParam( 42 ) ] + ], + 'AbortLogin, THROTTLED, with message' => [ + 'User', 'PaSsWoRd', null, \LoginForm::THROTTLED, 't', 't', + [ \Message::durationParam( 42 ) ] + ], + 'AbortLogin, USER_BLOCKED, no message' => [ + "User'", 'P', null, \LoginForm::USER_BLOCKED, null, 'login-userblocked', [ 'User'' ] + ], + 'AbortLogin, USER_BLOCKED, with message' => [ + "User'", 'P', null, \LoginForm::USER_BLOCKED, 'blocked', 'blocked', [ 'User'' ] + ], + 'AbortLogin, ABORTED, no message' => [ + "User'", 'P', null, \LoginForm::ABORTED, null, 'login-abort-generic', [ 'User'' ] + ], + 'AbortLogin, ABORTED, with message' => [ + "User'", 'P', null, \LoginForm::ABORTED, 'aborted', 'aborted', [ 'User'' ] + ], + 'AbortLogin, USER_MIGRATED, no message' => [ + 'User', 'P', null, \LoginForm::USER_MIGRATED, null, 'login-migrated-generic' + ], + 'AbortLogin, USER_MIGRATED, with message' => [ + 'User', 'P', null, \LoginForm::USER_MIGRATED, 'migrated', 'migrated' + ], + 'AbortLogin, USER_MIGRATED, with message and params' => [ + 'User', 'P', null, \LoginForm::USER_MIGRATED, [ 'migrated', 'foo' ], + 'migrated', [ 'foo' ] + ], + ]; + } + + /** + * @dataProvider provideTestForAccountCreation + * @param string $msg + * @param Status|null $status + * @param StatusValue Result + */ + public function testTestForAccountCreation( $msg, $status, $result ) { + $this->hook( 'AbortNewAccount', $this->once() ) + ->will( $this->returnCallback( function ( $user, &$error, &$abortStatus ) + use ( $msg, $status ) + { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( 'User', $user->getName() ); + $error = $msg; + $abortStatus = $status; + return $error === null && $status === null; + } ) ); + + $user = \User::newFromName( 'User' ); + $creator = \User::newFromName( 'UTSysop' ); + $ret = $this->getProvider()->testForAccountCreation( $user, $creator, [] ); + + $this->unhook( 'AbortNewAccount' ); + + $this->assertEquals( $result, $ret ); + } + + public static function provideTestForAccountCreation() { + return [ + 'No hook errors' => [ + null, null, \StatusValue::newGood() + ], + 'AbortNewAccount, old style' => [ + 'foobar', null, \StatusValue::newFatal( + \Message::newFromKey( 'createaccount-hook-aborted' )->rawParams( 'foobar' ) + ) + ], + 'AbortNewAccount, new style' => [ + 'foobar', + \Status::newFatal( 'aborted!', 'param' ), + \StatusValue::newFatal( 'aborted!', 'param' ) + ], + ]; + } + + /** + * @dataProvider provideTestUserForCreation + * @param string|null $error + * @param string|null $failMsg + */ + public function testTestUserForCreation( $error, $failMsg ) { + $testUser = self::getTestUser()->getUser(); + $provider = $this->getProvider(); + $options = [ 'flags' => \User::READ_LOCKING, 'creating' => true ]; + + $this->hook( 'AbortNewAccount', $this->never() ); + $this->hook( 'AbortAutoAccount', $this->once() ) + ->will( $this->returnCallback( function ( $user, &$abortError ) use ( $testUser, $error ) { + $this->assertInstanceOf( 'User', $user ); + $this->assertSame( $testUser->getName(), $user->getName() ); + $abortError = $error; + return $error === null; + } ) ); + $status = $provider->testUserForCreation( + $testUser, AuthManager::AUTOCREATE_SOURCE_SESSION, $options + ); + $this->unhook( 'AbortNewAccount' ); + $this->unhook( 'AbortAutoAccount' ); + if ( $failMsg === null ) { + $this->assertEquals( \StatusValue::newGood(), $status, 'should succeed' ); + } else { + $this->assertInstanceOf( 'StatusValue', $status, 'should fail (type)' ); + $this->assertFalse( $status->isOk(), 'should fail (ok)' ); + $errors = $status->getErrors(); + $this->assertEquals( $failMsg, $errors[0]['message'], 'should fail (message)' ); + } + + $this->hook( 'AbortAutoAccount', $this->never() ); + $this->hook( 'AbortNewAccount', $this->never() ); + $status = $provider->testUserForCreation( $testUser, false, $options ); + $this->unhook( 'AbortNewAccount' ); + $this->unhook( 'AbortAutoAccount' ); + $this->assertEquals( \StatusValue::newGood(), $status, 'should succeed' ); + } + + public static function provideTestUserForCreation() { + return [ + 'Success' => [ null, null ], + 'Fail, no message' => [ false, 'login-abort-generic' ], + 'Fail, with message' => [ 'fail', 'fail' ], + ]; + } +} diff --git a/tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..72a03c311a --- /dev/null +++ b/tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php @@ -0,0 +1,651 @@ +checkPasswordValidity is mocked to return $this->validity, + * because we don't need to test that here. + * + * @param bool $loginOnly + * @return LocalPasswordPrimaryAuthenticationProvider + */ + protected function getProvider( $loginOnly = false ) { + if ( !$this->config ) { + $this->config = new \HashConfig(); + } + $config = new \MultiConfig( [ + $this->config, + MediaWikiServices::getInstance()->getMainConfig() + ] ); + + if ( !$this->manager ) { + $this->manager = new AuthManager( new \FauxRequest(), $config ); + } + $this->validity = \Status::newGood(); + + $provider = $this->getMock( + LocalPasswordPrimaryAuthenticationProvider::class, + [ 'checkPasswordValidity' ], + [ [ 'loginOnly' => $loginOnly ] ] + ); + $provider->expects( $this->any() )->method( 'checkPasswordValidity' ) + ->will( $this->returnCallback( function () { + return $this->validity; + } ) ); + $provider->setConfig( $config ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setManager( $this->manager ); + + return $provider; + } + + public function testBasics() { + $user = $this->getMutableTestUser()->getUser(); + $userName = $user->getName(); + $lowerInitialUserName = mb_strtolower( $userName[0] ) . substr( $userName, 1 ); + + $provider = new LocalPasswordPrimaryAuthenticationProvider(); + + $this->assertSame( + PrimaryAuthenticationProvider::TYPE_CREATE, + $provider->accountCreationType() + ); + + $this->assertTrue( $provider->testUserExists( $userName ) ); + $this->assertTrue( $provider->testUserExists( $lowerInitialUserName ) ); + $this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) ); + $this->assertFalse( $provider->testUserExists( '' ) ); + + $provider = new LocalPasswordPrimaryAuthenticationProvider( [ 'loginOnly' => true ] ); + + $this->assertSame( + PrimaryAuthenticationProvider::TYPE_NONE, + $provider->accountCreationType() + ); + + $this->assertTrue( $provider->testUserExists( $userName ) ); + $this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) ); + + $req = new PasswordAuthenticationRequest; + $req->action = AuthManager::ACTION_CHANGE; + $req->username = ''; + $provider->providerChangeAuthenticationData( $req ); + } + + public function testTestUserCanAuthenticate() { + $user = $this->getMutableTestUser()->getUser(); + $userName = $user->getName(); + $dbw = wfGetDB( DB_MASTER ); + + $provider = $this->getProvider(); + + $this->assertFalse( $provider->testUserCanAuthenticate( '' ) ); + + $this->assertFalse( $provider->testUserCanAuthenticate( 'DoesNotExist' ) ); + + $this->assertTrue( $provider->testUserCanAuthenticate( $userName ) ); + $lowerInitialUserName = mb_strtolower( $userName[0] ) . substr( $userName, 1 ); + $this->assertTrue( $provider->testUserCanAuthenticate( $lowerInitialUserName ) ); + + $dbw->update( + 'user', + [ 'user_password' => \PasswordFactory::newInvalidPassword()->toString() ], + [ 'user_name' => $userName ] + ); + $this->assertFalse( $provider->testUserCanAuthenticate( $userName ) ); + + // Really old format + $dbw->update( + 'user', + [ 'user_password' => '0123456789abcdef0123456789abcdef' ], + [ 'user_name' => $userName ] + ); + $this->assertTrue( $provider->testUserCanAuthenticate( $userName ) ); + } + + public function testSetPasswordResetFlag() { + // Set instance vars + $this->getProvider(); + + /// @todo: Because we're currently using User, which uses the global config... + $this->setMwGlobals( [ 'wgPasswordExpireGrace' => 100 ] ); + + $this->config->set( 'PasswordExpireGrace', 100 ); + $this->config->set( 'InvalidPasswordReset', true ); + + $provider = new LocalPasswordPrimaryAuthenticationProvider(); + $provider->setConfig( $this->config ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setManager( $this->manager ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $user = $this->getMutableTestUser()->getUser(); + $userName = $user->getName(); + $dbw = wfGetDB( DB_MASTER ); + $row = $dbw->selectRow( + 'user', + '*', + [ 'user_name' => $userName ], + __METHOD__ + ); + + $this->manager->removeAuthenticationSessionData( null ); + $row->user_password_expires = wfTimestamp( TS_MW, time() + 200 ); + $providerPriv->setPasswordResetFlag( $userName, \Status::newGood(), $row ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) ); + + $this->manager->removeAuthenticationSessionData( null ); + $row->user_password_expires = wfTimestamp( TS_MW, time() - 200 ); + $providerPriv->setPasswordResetFlag( $userName, \Status::newGood(), $row ); + $ret = $this->manager->getAuthenticationSessionData( 'reset-pass' ); + $this->assertNotNull( $ret ); + $this->assertSame( 'resetpass-expired', $ret->msg->getKey() ); + $this->assertTrue( $ret->hard ); + + $this->manager->removeAuthenticationSessionData( null ); + $row->user_password_expires = wfTimestamp( TS_MW, time() - 1 ); + $providerPriv->setPasswordResetFlag( $userName, \Status::newGood(), $row ); + $ret = $this->manager->getAuthenticationSessionData( 'reset-pass' ); + $this->assertNotNull( $ret ); + $this->assertSame( 'resetpass-expired-soft', $ret->msg->getKey() ); + $this->assertFalse( $ret->hard ); + + $this->manager->removeAuthenticationSessionData( null ); + $row->user_password_expires = null; + $status = \Status::newGood(); + $status->error( 'testing' ); + $providerPriv->setPasswordResetFlag( $userName, $status, $row ); + $ret = $this->manager->getAuthenticationSessionData( 'reset-pass' ); + $this->assertNotNull( $ret ); + $this->assertSame( 'resetpass-validity-soft', $ret->msg->getKey() ); + $this->assertFalse( $ret->hard ); + } + + public function testAuthentication() { + $testUser = $this->getMutableTestUser(); + $userName = $testUser->getUser()->getName(); + + $dbw = wfGetDB( DB_MASTER ); + $id = \User::idFromName( $userName ); + + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_LOGIN; + $reqs = [ PasswordAuthenticationRequest::class => $req ]; + + $provider = $this->getProvider(); + + // General failures + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( [] ) + ); + + $req->username = 'foo'; + $req->password = null; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = null; + $req->password = 'bar'; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = ''; + $req->password = 'WhoCares'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = 'DoesNotExist'; + $req->password = 'DoesNotExist'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + // Validation failure + $req->username = $userName; + $req->password = $testUser->getPassword(); + $this->validity = \Status::newFatal( 'arbitrary-failure' ); + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'arbitrary-failure', + $ret->message->getKey() + ); + + // Successful auth + $this->manager->removeAuthenticationSessionData( null ); + $this->validity = \Status::newGood(); + $this->assertEquals( + AuthenticationResponse::newPass( $userName ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) ); + + // Successful auth after normalizing name + $this->manager->removeAuthenticationSessionData( null ); + $this->validity = \Status::newGood(); + $req->username = mb_strtolower( $userName[0] ) . substr( $userName, 1 ); + $this->assertEquals( + AuthenticationResponse::newPass( $userName ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) ); + $req->username = $userName; + + // Successful auth with reset + $this->manager->removeAuthenticationSessionData( null ); + $this->validity->error( 'arbitrary-warning' ); + $this->assertEquals( + AuthenticationResponse::newPass( $userName ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + $this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) ); + + // Wrong password + $this->validity = \Status::newGood(); + $req->password = 'Wrong'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey() + ); + + // Correct handling of legacy encodings + $password = ':B:salt:' . md5( 'salt-' . md5( "\xe1\xe9\xed\xf3\xfa" ) ); + $dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => $userName ] ); + $req->password = 'áéíóú'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey() + ); + + $this->config->set( 'LegacyEncoding', true ); + $this->assertEquals( + AuthenticationResponse::newPass( $userName ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->password = 'áéíóú Wrong'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey() + ); + + // Correct handling of really old password hashes + $this->config->set( 'PasswordSalt', false ); + $password = md5( 'FooBar' ); + $dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => $userName ] ); + $req->password = 'FooBar'; + $this->assertEquals( + AuthenticationResponse::newPass( $userName ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $this->config->set( 'PasswordSalt', true ); + $password = md5( "$id-" . md5( 'FooBar' ) ); + $dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => $userName ] ); + $req->password = 'FooBar'; + $this->assertEquals( + AuthenticationResponse::newPass( $userName ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + } + + /** + * @dataProvider provideProviderAllowsAuthenticationDataChange + * @param string $type + * @param string $user + * @param \Status $validity Result of the password validity check + * @param \StatusValue $expect1 Expected result with $checkData = false + * @param \StatusValue $expect2 Expected result with $checkData = true + */ + public function testProviderAllowsAuthenticationDataChange( $type, $user, \Status $validity, + \StatusValue $expect1, \StatusValue $expect2 + ) { + if ( $type === PasswordAuthenticationRequest::class ) { + $req = new $type(); + } elseif ( $type === PasswordDomainAuthenticationRequest::class ) { + $req = new $type( [] ); + } else { + $req = $this->getMock( $type ); + } + $req->action = AuthManager::ACTION_CHANGE; + $req->username = $user; + $req->password = 'NewPassword'; + $req->retype = 'NewPassword'; + + $provider = $this->getProvider(); + $this->validity = $validity; + $this->assertEquals( $expect1, $provider->providerAllowsAuthenticationDataChange( $req, false ) ); + $this->assertEquals( $expect2, $provider->providerAllowsAuthenticationDataChange( $req, true ) ); + + $req->retype = 'BadRetype'; + $this->assertEquals( + $expect1, + $provider->providerAllowsAuthenticationDataChange( $req, false ) + ); + $this->assertEquals( + $expect2->getValue() === 'ignored' ? $expect2 : \StatusValue::newFatal( 'badretype' ), + $provider->providerAllowsAuthenticationDataChange( $req, true ) + ); + + $provider = $this->getProvider( true ); + $this->assertEquals( + \StatusValue::newGood( 'ignored' ), + $provider->providerAllowsAuthenticationDataChange( $req, true ), + 'loginOnly mode should claim to ignore all changes' + ); + } + + public static function provideProviderAllowsAuthenticationDataChange() { + $err = \StatusValue::newGood(); + $err->error( 'arbitrary-warning' ); + + return [ + [ AuthenticationRequest::class, 'UTSysop', \Status::newGood(), + \StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ], + [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood() ], + [ PasswordAuthenticationRequest::class, 'uTSysop', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood() ], + [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::wrap( $err ), + \StatusValue::newGood(), $err ], + [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::newFatal( 'arbitrary-error' ), + \StatusValue::newGood(), \StatusValue::newFatal( 'arbitrary-error' ) ], + [ PasswordAuthenticationRequest::class, 'DoesNotExist', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood( 'ignored' ) ], + [ PasswordDomainAuthenticationRequest::class, 'UTSysop', \Status::newGood(), + \StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ], + ]; + } + + /** + * @dataProvider provideProviderChangeAuthenticationData + * @param callable|bool $usernameTransform + * @param string $type + * @param bool $loginOnly + * @param bool $changed + */ + public function testProviderChangeAuthenticationData( + $usernameTransform, $type, $loginOnly, $changed ) { + $testUser = $this->getMutableTestUser(); + $user = $testUser->getUser()->getName(); + if ( is_callable( $usernameTransform ) ) { + $user = call_user_func( $usernameTransform, $user ); + } + $cuser = ucfirst( $user ); + $oldpass = $testUser->getPassword(); + $newpass = 'NewPassword'; + + $dbw = wfGetDB( DB_MASTER ); + $oldExpiry = $dbw->selectField( 'user', 'user_password_expires', [ 'user_name' => $cuser ] ); + + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + 'ResetPasswordExpiration' => [ function ( $user, &$expires ) { + $expires = '30001231235959'; + } ] + ] ); + + $provider = $this->getProvider( $loginOnly ); + + // Sanity check + $loginReq = new PasswordAuthenticationRequest(); + $loginReq->action = AuthManager::ACTION_LOGIN; + $loginReq->username = $user; + $loginReq->password = $oldpass; + $loginReqs = [ PasswordAuthenticationRequest::class => $loginReq ]; + $this->assertEquals( + AuthenticationResponse::newPass( $cuser ), + $provider->beginPrimaryAuthentication( $loginReqs ), + 'Sanity check' + ); + + if ( $type === PasswordAuthenticationRequest::class ) { + $changeReq = new $type(); + } else { + $changeReq = $this->getMock( $type ); + } + $changeReq->action = AuthManager::ACTION_CHANGE; + $changeReq->username = $user; + $changeReq->password = $newpass; + $provider->providerChangeAuthenticationData( $changeReq ); + + if ( $loginOnly && $changed ) { + $old = 'fail'; + $new = 'fail'; + $expectExpiry = null; + } elseif ( $changed ) { + $old = 'fail'; + $new = 'pass'; + $expectExpiry = '30001231235959'; + } else { + $old = 'pass'; + $new = 'fail'; + $expectExpiry = $oldExpiry; + } + + $loginReq->password = $oldpass; + $ret = $provider->beginPrimaryAuthentication( $loginReqs ); + if ( $old === 'pass' ) { + $this->assertEquals( + AuthenticationResponse::newPass( $cuser ), + $ret, + 'old password should pass' + ); + } else { + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status, + 'old password should fail' + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey(), + 'old password should fail' + ); + } + + $loginReq->password = $newpass; + $ret = $provider->beginPrimaryAuthentication( $loginReqs ); + if ( $new === 'pass' ) { + $this->assertEquals( + AuthenticationResponse::newPass( $cuser ), + $ret, + 'new password should pass' + ); + } else { + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status, + 'new password should fail' + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey(), + 'new password should fail' + ); + } + + $this->assertSame( + $expectExpiry, + $dbw->selectField( 'user', 'user_password_expires', [ 'user_name' => $cuser ] ) + ); + } + + public static function provideProviderChangeAuthenticationData() { + return [ + [ false, AuthenticationRequest::class, false, false ], + [ false, PasswordAuthenticationRequest::class, false, true ], + [ false, AuthenticationRequest::class, true, false ], + [ false, PasswordAuthenticationRequest::class, true, true ], + [ 'ucfirst', PasswordAuthenticationRequest::class, false, true ], + [ 'ucfirst', PasswordAuthenticationRequest::class, true, true ], + ]; + } + + public function testTestForAccountCreation() { + $user = \User::newFromName( 'foo' ); + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_CREATE; + $req->username = 'Foo'; + $req->password = 'Bar'; + $req->retype = 'Bar'; + $reqs = [ PasswordAuthenticationRequest::class => $req ]; + + $provider = $this->getProvider(); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ), + 'No password request' + ); + + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, $reqs ), + 'Password request, validated' + ); + + $req->retype = 'Baz'; + $this->assertEquals( + \StatusValue::newFatal( 'badretype' ), + $provider->testForAccountCreation( $user, $user, $reqs ), + 'Password request, bad retype' + ); + $req->retype = 'Bar'; + + $this->validity->error( 'arbitrary warning' ); + $expect = \StatusValue::newGood(); + $expect->error( 'arbitrary warning' ); + $this->assertEquals( + $expect, + $provider->testForAccountCreation( $user, $user, $reqs ), + 'Password request, not validated' + ); + + $provider = $this->getProvider( true ); + $this->validity->error( 'arbitrary warning' ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, $reqs ), + 'Password request, not validated, loginOnly' + ); + } + + public function testAccountCreation() { + $user = \User::newFromName( 'Foo' ); + + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_CREATE; + $reqs = [ PasswordAuthenticationRequest::class => $req ]; + + $provider = $this->getProvider( true ); + try { + $provider->beginPrimaryAccountCreation( $user, $user, [] ); + $this->fail( 'Expected exception was not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( + 'Shouldn\'t call this when accountCreationType() is NONE', $ex->getMessage() + ); + } + + try { + $provider->finishAccountCreation( $user, $user, AuthenticationResponse::newPass() ); + $this->fail( 'Expected exception was not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( + 'Shouldn\'t call this when accountCreationType() is NONE', $ex->getMessage() + ); + } + + $provider = $this->getProvider( false ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, [] ) + ); + + $req->username = 'foo'; + $req->password = null; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $req->username = null; + $req->password = 'bar'; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $req->username = 'foo'; + $req->password = 'bar'; + + $expect = AuthenticationResponse::newPass( 'Foo' ); + $expect->createRequest = clone( $req ); + $expect->createRequest->username = 'Foo'; + $this->assertEquals( $expect, $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) ); + + // We have to cheat a bit to avoid having to add a new user to + // the database to test the actual setting of the password works right + $dbw = wfGetDB( DB_MASTER ); + + $user = \User::newFromName( 'UTSysop' ); + $req->username = $user->getName(); + $req->password = 'NewPassword'; + $expect = AuthenticationResponse::newPass( 'UTSysop' ); + $expect->createRequest = $req; + + $res2 = $provider->beginPrimaryAccountCreation( $user, $user, $reqs ); + $this->assertEquals( $expect, $res2, 'Sanity check' ); + + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( AuthenticationResponse::FAIL, $ret->status, 'sanity check' ); + + $this->assertNull( $provider->finishAccountCreation( $user, $user, $res2 ) ); + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( AuthenticationResponse::PASS, $ret->status, 'new password is set' ); + } + +} diff --git a/tests/phpunit/includes/auth/PasswordAuthenticationRequestTest.php b/tests/phpunit/includes/auth/PasswordAuthenticationRequestTest.php new file mode 100644 index 0000000000..3387e7c9ac --- /dev/null +++ b/tests/phpunit/includes/auth/PasswordAuthenticationRequestTest.php @@ -0,0 +1,138 @@ +action = $args[0]; + return $ret; + } + + public static function provideGetFieldInfo() { + return [ + [ [ AuthManager::ACTION_LOGIN ] ], + [ [ AuthManager::ACTION_CREATE ] ], + [ [ AuthManager::ACTION_CHANGE ] ], + [ [ AuthManager::ACTION_REMOVE ] ], + ]; + } + + public function testGetFieldInfo2() { + $info = []; + foreach ( [ + AuthManager::ACTION_LOGIN, + AuthManager::ACTION_CREATE, + AuthManager::ACTION_CHANGE, + AuthManager::ACTION_REMOVE, + ] as $action ) { + $req = new PasswordAuthenticationRequest(); + $req->action = $action; + $info[$action] = $req->getFieldInfo(); + } + + $this->assertSame( [], $info[AuthManager::ACTION_REMOVE], 'No data needed to remove' ); + + $this->assertArrayNotHasKey( 'retype', $info[AuthManager::ACTION_LOGIN], + 'No need to retype password on login' ); + $this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CREATE], + 'Need to retype when creating new password' ); + $this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CHANGE], + 'Need to retype when changing password' ); + + $this->assertNotEquals( + $info[AuthManager::ACTION_LOGIN]['password']['label'], + $info[AuthManager::ACTION_CHANGE]['password']['label'], + 'Password field for change is differentiated from login' + ); + $this->assertNotEquals( + $info[AuthManager::ACTION_CREATE]['password']['label'], + $info[AuthManager::ACTION_CHANGE]['password']['label'], + 'Password field for change is differentiated from create' + ); + $this->assertNotEquals( + $info[AuthManager::ACTION_CREATE]['retype']['label'], + $info[AuthManager::ACTION_CHANGE]['retype']['label'], + 'Retype field for change is differentiated from create' + ); + } + + public function provideLoadFromSubmission() { + return [ + 'Empty request, login' => [ + [ AuthManager::ACTION_LOGIN ], + [], + false, + ], + 'Empty request, change' => [ + [ AuthManager::ACTION_CHANGE ], + [], + false, + ], + 'Empty request, remove' => [ + [ AuthManager::ACTION_REMOVE ], + [], + false, + ], + 'Username + password, login' => [ + [ AuthManager::ACTION_LOGIN ], + $data = [ 'username' => 'User', 'password' => 'Bar' ], + $data + [ 'action' => AuthManager::ACTION_LOGIN ], + ], + 'Username + password, change' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => 'User', 'password' => 'Bar' ], + false, + ], + 'Username + password + retype' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => 'User', 'password' => 'Bar', 'retype' => 'baz' ], + [ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ], + ], + 'Username empty, login' => [ + [ AuthManager::ACTION_LOGIN ], + [ 'username' => '', 'password' => 'Bar' ], + false, + ], + 'Username empty, change' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => '', 'password' => 'Bar', 'retype' => 'baz' ], + [ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ], + ], + 'Password empty, login' => [ + [ AuthManager::ACTION_LOGIN ], + [ 'username' => 'User', 'password' => '' ], + false, + ], + 'Password empty, login, with retype' => [ + [ AuthManager::ACTION_LOGIN ], + [ 'username' => 'User', 'password' => '', 'retype' => 'baz' ], + false, + ], + 'Retype empty' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => 'User', 'password' => 'Bar', 'retype' => '' ], + false, + ], + ]; + } + + public function testDescribeCredentials() { + $req = new PasswordAuthenticationRequest; + $req->action = AuthManager::ACTION_LOGIN; + $req->username = 'UTSysop'; + $ret = $req->describeCredentials(); + $this->assertInternalType( 'array', $ret ); + $this->assertArrayHasKey( 'provider', $ret ); + $this->assertInstanceOf( 'Message', $ret['provider'] ); + $this->assertSame( 'authmanager-provider-password', $ret['provider']->getKey() ); + $this->assertArrayHasKey( 'account', $ret ); + $this->assertInstanceOf( 'Message', $ret['account'] ); + $this->assertSame( [ 'UTSysop' ], $ret['account']->getParams() ); + } +} diff --git a/tests/phpunit/includes/auth/PasswordDomainAuthenticationRequestTest.php b/tests/phpunit/includes/auth/PasswordDomainAuthenticationRequestTest.php new file mode 100644 index 0000000000..f746515b09 --- /dev/null +++ b/tests/phpunit/includes/auth/PasswordDomainAuthenticationRequestTest.php @@ -0,0 +1,159 @@ +action = $args[0]; + return $ret; + } + + public static function provideGetFieldInfo() { + return [ + [ [ AuthManager::ACTION_LOGIN ] ], + [ [ AuthManager::ACTION_CREATE ] ], + [ [ AuthManager::ACTION_CHANGE ] ], + [ [ AuthManager::ACTION_REMOVE ] ], + ]; + } + + public function testGetFieldInfo2() { + $info = []; + foreach ( [ + AuthManager::ACTION_LOGIN, + AuthManager::ACTION_CREATE, + AuthManager::ACTION_CHANGE, + AuthManager::ACTION_REMOVE, + ] as $action ) { + $req = new PasswordDomainAuthenticationRequest( [ 'd1', 'd2' ] ); + $req->action = $action; + $info[$action] = $req->getFieldInfo(); + } + + $this->assertSame( [], $info[AuthManager::ACTION_REMOVE], 'No data needed to remove' ); + + $this->assertArrayNotHasKey( 'retype', $info[AuthManager::ACTION_LOGIN], + 'No need to retype password on login' ); + $this->assertArrayHasKey( 'domain', $info[AuthManager::ACTION_LOGIN], + 'Domain needed on login' ); + $this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CREATE], + 'Need to retype when creating new password' ); + $this->assertArrayHasKey( 'domain', $info[AuthManager::ACTION_CREATE], + 'Domain needed on account creation' ); + $this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CHANGE], + 'Need to retype when changing password' ); + $this->assertArrayNotHasKey( 'domain', $info[AuthManager::ACTION_CHANGE], + 'Domain not needed on account creation' ); + + $this->assertNotEquals( + $info[AuthManager::ACTION_LOGIN]['password']['label'], + $info[AuthManager::ACTION_CHANGE]['password']['label'], + 'Password field for change is differentiated from login' + ); + $this->assertNotEquals( + $info[AuthManager::ACTION_CREATE]['password']['label'], + $info[AuthManager::ACTION_CHANGE]['password']['label'], + 'Password field for change is differentiated from create' + ); + $this->assertNotEquals( + $info[AuthManager::ACTION_CREATE]['retype']['label'], + $info[AuthManager::ACTION_CHANGE]['retype']['label'], + 'Retype field for change is differentiated from create' + ); + } + + public function provideLoadFromSubmission() { + $domainList = [ 'domainList' => [ 'd1', 'd2' ] ]; + return [ + 'Empty request, login' => [ + [ AuthManager::ACTION_LOGIN ], + [], + false, + ], + 'Empty request, change' => [ + [ AuthManager::ACTION_CHANGE ], + [], + false, + ], + 'Empty request, remove' => [ + [ AuthManager::ACTION_REMOVE ], + [], + false, + ], + 'Username + password, login' => [ + [ AuthManager::ACTION_LOGIN ], + $data = [ 'username' => 'User', 'password' => 'Bar' ], + false, + ], + 'Username + password + domain, login' => [ + [ AuthManager::ACTION_LOGIN ], + $data = [ 'username' => 'User', 'password' => 'Bar', 'domain' => 'd1' ], + $data + [ 'action' => AuthManager::ACTION_LOGIN ] + $domainList, + ], + 'Username + password + bad domain, login' => [ + [ AuthManager::ACTION_LOGIN ], + $data = [ 'username' => 'User', 'password' => 'Bar', 'domain' => 'd5' ], + false, + ], + 'Username + password + domain, change' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => 'User', 'password' => 'Bar', 'domain' => 'd1' ], + false, + ], + 'Username + password + domain + retype' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => 'User', 'password' => 'Bar', 'retype' => 'baz', 'domain' => 'd1' ], + [ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ] + + $domainList, + ], + 'Username empty, login' => [ + [ AuthManager::ACTION_LOGIN ], + [ 'username' => '', 'password' => 'Bar', 'domain' => 'd1' ], + false, + ], + 'Username empty, change' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => '', 'password' => 'Bar', 'retype' => 'baz', 'domain' => 'd1' ], + [ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ] + + $domainList, + ], + 'Password empty, login' => [ + [ AuthManager::ACTION_LOGIN ], + [ 'username' => 'User', 'password' => '', 'domain' => 'd1' ], + false, + ], + 'Password empty, login, with retype' => [ + [ AuthManager::ACTION_LOGIN ], + [ 'username' => 'User', 'password' => '', 'retype' => 'baz', 'domain' => 'd1' ], + false, + ], + 'Retype empty' => [ + [ AuthManager::ACTION_CHANGE ], + [ 'username' => 'User', 'password' => 'Bar', 'retype' => '', 'domain' => 'd1' ], + false, + ], + ]; + } + + public function testDescribeCredentials() { + $req = new PasswordDomainAuthenticationRequest( [ 'd1', 'd2' ] ); + $req->action = AuthManager::ACTION_LOGIN; + $req->username = 'UTSysop'; + $req->domain = 'd2'; + $ret = $req->describeCredentials(); + $this->assertInternalType( 'array', $ret ); + $this->assertArrayHasKey( 'provider', $ret ); + $this->assertInstanceOf( 'Message', $ret['provider'] ); + $this->assertSame( 'authmanager-provider-password-domain', $ret['provider']->getKey() ); + $this->assertArrayHasKey( 'account', $ret ); + $this->assertInstanceOf( 'Message', $ret['account'] ); + $this->assertSame( 'authmanager-account-password-domain', $ret['account']->getKey() ); + $this->assertSame( [ 'UTSysop', 'd2' ], $ret['account']->getParams() ); + } +} diff --git a/tests/phpunit/includes/auth/RememberMeAuthenticationRequestTest.php b/tests/phpunit/includes/auth/RememberMeAuthenticationRequestTest.php new file mode 100644 index 0000000000..3f90169cac --- /dev/null +++ b/tests/phpunit/includes/auth/RememberMeAuthenticationRequestTest.php @@ -0,0 +1,55 @@ +expiration = 30 * 24 * 3600; + $this->assertNotEmpty( $req->getFieldInfo() ); + + $reqWrapper->expiration = null; + $this->assertEmpty( $req->getFieldInfo() ); + } + + protected function getInstance( array $args = [] ) { + $req = new RememberMeAuthenticationRequest(); + $reqWrapper = \TestingAccessWrapper::newFromObject( $req ); + $reqWrapper->expiration = $args[0]; + return $req; + } + + public function provideLoadFromSubmission() { + return [ + 'Empty request' => [ + [ 30 * 24 * 3600 ], + [], + [ 'expiration' => 30 * 24 * 3600, 'rememberMe' => false ] + ], + 'RememberMe present' => [ + [ 30 * 24 * 3600 ], + [ 'rememberMe' => '' ], + [ 'expiration' => 30 * 24 * 3600, 'rememberMe' => true ] + ], + 'RememberMe present but session provider cannot remember' => [ + [ null ], + [ 'rememberMe' => '' ], + false + ], + ]; + } +} diff --git a/tests/phpunit/includes/auth/ResetPasswordSecondaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/ResetPasswordSecondaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..90ed54274d --- /dev/null +++ b/tests/phpunit/includes/auth/ResetPasswordSecondaryAuthenticationProviderTest.php @@ -0,0 +1,308 @@ +assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public static function provideGetAuthenticationRequests() { + return [ + [ AuthManager::ACTION_LOGIN, [] ], + [ AuthManager::ACTION_CREATE, [] ], + [ AuthManager::ACTION_LINK, [] ], + [ AuthManager::ACTION_CHANGE, [] ], + [ AuthManager::ACTION_REMOVE, [] ], + ]; + } + + public function testBasics() { + $user = \User::newFromName( 'UTSysop' ); + $user2 = new \User; + $obj = new \stdClass; + $reqs = [ new \stdClass ]; + + $mb = $this->getMockBuilder( ResetPasswordSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'tryReset' ] ); + + $methods = [ + 'beginSecondaryAuthentication' => [ $user, $reqs ], + 'continueSecondaryAuthentication' => [ $user, $reqs ], + 'beginSecondaryAccountCreation' => [ $user, $user2, $reqs ], + 'continueSecondaryAccountCreation' => [ $user, $user2, $reqs ], + ]; + foreach ( $methods as $method => $args ) { + $mock = $mb->getMock(); + $mock->expects( $this->once() )->method( 'tryReset' ) + ->with( $this->identicalTo( $user ), $this->identicalTo( $reqs ) ) + ->will( $this->returnValue( $obj ) ); + $this->assertSame( $obj, call_user_func_array( [ $mock, $method ], $args ) ); + } + } + + public function testTryReset() { + $user = \User::newFromName( 'UTSysop' ); + + $provider = $this->getMockBuilder( + ResetPasswordSecondaryAuthenticationProvider::class + ) + ->setMethods( [ + 'providerAllowsAuthenticationDataChange', 'providerChangeAuthenticationData' + ] ) + ->getMock(); + $provider->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' ) + ->will( $this->returnCallback( function ( $req ) { + $this->assertSame( 'UTSysop', $req->username ); + return $req->allow; + } ) ); + $provider->expects( $this->any() )->method( 'providerChangeAuthenticationData' ) + ->will( $this->returnCallback( function ( $req ) { + $this->assertSame( 'UTSysop', $req->username ); + $req->done = true; + } ) ); + $config = new \HashConfig( [ + 'AuthManagerConfig' => [ + 'preauth' => [], + 'primaryauth' => [], + 'secondaryauth' => [ + [ 'factory' => function () use ( $provider ) { + return $provider; + } ], + ], + ], + ] ); + $manager = new AuthManager( new \FauxRequest, $config ); + $provider->setManager( $manager ); + $provider = \TestingAccessWrapper::newFromObject( $provider ); + + $msg = wfMessage( 'foo' ); + $skipReq = new ButtonAuthenticationRequest( + 'skipReset', + wfMessage( 'authprovider-resetpass-skip-label' ), + wfMessage( 'authprovider-resetpass-skip-help' ) + ); + $passReq = new PasswordAuthenticationRequest(); + $passReq->action = AuthManager::ACTION_CHANGE; + $passReq->password = 'Foo'; + $passReq->retype = 'Bar'; + $passReq->allow = \StatusValue::newGood(); + $passReq->done = false; + + $passReq2 = $this->getMockBuilder( PasswordAuthenticationRequest::class ) + ->enableProxyingToOriginalMethods() + ->getMock(); + $passReq2->action = AuthManager::ACTION_CHANGE; + $passReq2->password = 'Foo'; + $passReq2->retype = 'Foo'; + $passReq2->allow = \StatusValue::newGood(); + $passReq2->done = false; + + $passReq3 = new PasswordAuthenticationRequest(); + $passReq3->action = AuthManager::ACTION_LOGIN; + $passReq3->password = 'Foo'; + $passReq3->retype = 'Foo'; + $passReq3->allow = \StatusValue::newGood(); + $passReq3->done = false; + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->tryReset( $user, [] ) + ); + + $manager->setAuthenticationSessionData( 'reset-pass', 'foo' ); + try { + $provider->tryReset( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'reset-pass is not valid', $ex->getMessage() ); + } + + $manager->setAuthenticationSessionData( 'reset-pass', (object)[] ); + try { + $provider->tryReset( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'reset-pass msg is missing', $ex->getMessage() ); + } + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => 'foo', + ] ); + try { + $provider->tryReset( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'reset-pass msg is not valid', $ex->getMessage() ); + } + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + ] ); + try { + $provider->tryReset( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'reset-pass hard is missing', $ex->getMessage() ); + } + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => true, + 'req' => 'foo', + ] ); + try { + $provider->tryReset( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'reset-pass req is not valid', $ex->getMessage() ); + } + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => false, + 'req' => $passReq3, + ] ); + try { + $provider->tryReset( $user, [ $passReq ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'reset-pass req is not valid', $ex->getMessage() ); + } + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => true, + ] ); + $res = $provider->tryReset( $user, [] ); + $this->assertInstanceOf( AuthenticationResponse::class, $res ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertEquals( $msg, $res->message ); + $this->assertCount( 1, $res->neededRequests ); + $this->assertInstanceOf( + PasswordAuthenticationRequest::class, + $res->neededRequests[0] + ); + $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => false, + 'req' => $passReq, + ] ); + $res = $provider->tryReset( $user, [] ); + $this->assertInstanceOf( AuthenticationResponse::class, $res ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertEquals( $msg, $res->message ); + $this->assertCount( 2, $res->neededRequests ); + $expectedPassReq = clone $passReq; + $expectedPassReq->required = AuthenticationRequest::OPTIONAL; + $this->assertEquals( $expectedPassReq, $res->neededRequests[0] ); + $this->assertEquals( $skipReq, $res->neededRequests[1] ); + $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + + $passReq->retype = 'Bad'; + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => false, + 'req' => $passReq, + ] ); + $res = $provider->tryReset( $user, [ $skipReq, $passReq ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + + $passReq->retype = 'Bad'; + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => true, + ] ); + $res = $provider->tryReset( $user, [ $skipReq, $passReq ] ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertSame( 'badretype', $res->message->getKey() ); + $this->assertCount( 1, $res->neededRequests ); + $this->assertInstanceOf( + PasswordAuthenticationRequest::class, + $res->neededRequests[0] + ); + $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => true, + ] ); + $res = $provider->tryReset( $user, [ $skipReq, $passReq3 ] ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertEquals( $msg, $res->message ); + $this->assertCount( 1, $res->neededRequests ); + $this->assertInstanceOf( + PasswordAuthenticationRequest::class, + $res->neededRequests[0] + ); + $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + + $passReq->retype = $passReq->password; + $passReq->allow = \StatusValue::newFatal( 'arbitrary-fail' ); + $res = $provider->tryReset( $user, [ $skipReq, $passReq ] ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertSame( 'arbitrary-fail', $res->message->getKey() ); + $this->assertCount( 1, $res->neededRequests ); + $this->assertInstanceOf( + PasswordAuthenticationRequest::class, + $res->neededRequests[0] + ); + $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + + $passReq->allow = \StatusValue::newGood(); + $res = $provider->tryReset( $user, [ $skipReq, $passReq ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertTrue( $passReq->done ); + + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => false, + 'req' => $passReq2, + ] ); + $res = $provider->tryReset( $user, [ $passReq2 ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertTrue( $passReq2->done ); + + $passReq->done = false; + $passReq2->done = false; + $manager->setAuthenticationSessionData( 'reset-pass', [ + 'msg' => $msg, + 'hard' => false, + 'req' => $passReq2, + ] ); + $res = $provider->tryReset( $user, [ $passReq ] ); + $this->assertInstanceOf( AuthenticationResponse::class, $res ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertEquals( $msg, $res->message ); + $this->assertCount( 2, $res->neededRequests ); + $expectedPassReq = clone $passReq2; + $expectedPassReq->required = AuthenticationRequest::OPTIONAL; + $this->assertEquals( $expectedPassReq, $res->neededRequests[0] ); + $this->assertEquals( $skipReq, $res->neededRequests[1] ); + $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) ); + $this->assertFalse( $passReq->done ); + $this->assertFalse( $passReq2->done ); + } +} diff --git a/tests/phpunit/includes/auth/TemporaryPasswordAuthenticationRequestTest.php b/tests/phpunit/includes/auth/TemporaryPasswordAuthenticationRequestTest.php new file mode 100644 index 0000000000..05c5165b44 --- /dev/null +++ b/tests/phpunit/includes/auth/TemporaryPasswordAuthenticationRequestTest.php @@ -0,0 +1,79 @@ +action = $args[0]; + return $ret; + } + + public static function provideGetFieldInfo() { + return [ + [ [ AuthManager::ACTION_CREATE ] ], + [ [ AuthManager::ACTION_CHANGE ] ], + [ [ AuthManager::ACTION_REMOVE ] ], + ]; + } + + public function testNewRandom() { + global $wgPasswordPolicy; + + $this->stashMwGlobals( 'wgPasswordPolicy' ); + $wgPasswordPolicy['policies']['default'] += [ + 'MinimalPasswordLength' => 1, + 'MinimalPasswordLengthToLogin' => 1, + ]; + + $ret1 = TemporaryPasswordAuthenticationRequest::newRandom(); + $ret2 = TemporaryPasswordAuthenticationRequest::newRandom(); + $this->assertNotSame( '', $ret1->password ); + $this->assertNotSame( '', $ret2->password ); + $this->assertNotSame( $ret1->password, $ret2->password ); + } + + public function testNewInvalid() { + $ret = TemporaryPasswordAuthenticationRequest::newInvalid(); + $this->assertNull( $ret->password ); + } + + public function provideLoadFromSubmission() { + return [ + 'Empty request' => [ + [ AuthManager::ACTION_REMOVE ], + [], + false, + ], + 'Create, empty request' => [ + [ AuthManager::ACTION_CREATE ], + [], + false, + ], + 'Create, mailpassword set' => [ + [ AuthManager::ACTION_CREATE ], + [ 'mailpassword' => 1 ], + [ 'mailpassword' => true, 'action' => AuthManager::ACTION_CREATE ], + ], + ]; + } + + public function testDescribeCredentials() { + $req = new TemporaryPasswordAuthenticationRequest; + $req->action = AuthManager::ACTION_LOGIN; + $req->username = 'UTSysop'; + $ret = $req->describeCredentials(); + $this->assertInternalType( 'array', $ret ); + $this->assertArrayHasKey( 'provider', $ret ); + $this->assertInstanceOf( 'Message', $ret['provider'] ); + $this->assertSame( 'authmanager-provider-temporarypassword', $ret['provider']->getKey() ); + $this->assertArrayHasKey( 'account', $ret ); + $this->assertInstanceOf( 'Message', $ret['account'] ); + $this->assertSame( [ 'UTSysop' ], $ret['account']->getParams() ); + } +} diff --git a/tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..bc7d65e81d --- /dev/null +++ b/tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php @@ -0,0 +1,720 @@ +checkPasswordValidity is mocked to return $this->validity, + * because we don't need to test that here. + * + * @param array $params + * @return TemporaryPasswordPrimaryAuthenticationProvider + */ + protected function getProvider( $params = [] ) { + if ( !$this->config ) { + $this->config = new \HashConfig( [ + 'EmailEnabled' => true, + ] ); + } + $config = new \MultiConfig( [ + $this->config, + MediaWikiServices::getInstance()->getMainConfig() + ] ); + + if ( !$this->manager ) { + $this->manager = new AuthManager( new \FauxRequest(), $config ); + } + $this->validity = \Status::newGood(); + + $mockedMethods[] = 'checkPasswordValidity'; + $provider = $this->getMock( + TemporaryPasswordPrimaryAuthenticationProvider::class, + $mockedMethods, + [ $params ] + ); + $provider->expects( $this->any() )->method( 'checkPasswordValidity' ) + ->will( $this->returnCallback( function () { + return $this->validity; + } ) ); + $provider->setConfig( $config ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setManager( $this->manager ); + + return $provider; + } + + protected function hookMailer( $func = null ) { + \Hooks::clear( 'AlternateUserMailer' ); + if ( $func ) { + \Hooks::register( 'AlternateUserMailer', $func ); + // Safety + \Hooks::register( 'AlternateUserMailer', function () { + return false; + } ); + } else { + \Hooks::register( 'AlternateUserMailer', function () { + $this->fail( 'AlternateUserMailer hook called unexpectedly' ); + return false; + } ); + } + + return new ScopedCallback( function () { + \Hooks::clear( 'AlternateUserMailer' ); + \Hooks::register( 'AlternateUserMailer', function () { + return false; + } ); + } ); + } + + public function testBasics() { + $provider = new TemporaryPasswordPrimaryAuthenticationProvider(); + + $this->assertSame( + PrimaryAuthenticationProvider::TYPE_CREATE, + $provider->accountCreationType() + ); + + $this->assertTrue( $provider->testUserExists( 'UTSysop' ) ); + $this->assertTrue( $provider->testUserExists( 'uTSysop' ) ); + $this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) ); + $this->assertFalse( $provider->testUserExists( '' ) ); + + $req = new PasswordAuthenticationRequest; + $req->action = AuthManager::ACTION_CHANGE; + $req->username = ''; + $provider->providerChangeAuthenticationData( $req ); + } + + public function testConfig() { + $config = new \HashConfig( [ + 'EnableEmail' => false, + 'NewPasswordExpiry' => 100, + 'PasswordReminderResendTime' => 101, + ] ); + + $p = \TestingAccessWrapper::newFromObject( new TemporaryPasswordPrimaryAuthenticationProvider() ); + $p->setConfig( $config ); + $this->assertSame( false, $p->emailEnabled ); + $this->assertSame( 100, $p->newPasswordExpiry ); + $this->assertSame( 101, $p->passwordReminderResendTime ); + + $p = \TestingAccessWrapper::newFromObject( new TemporaryPasswordPrimaryAuthenticationProvider( [ + 'emailEnabled' => true, + 'newPasswordExpiry' => 42, + 'passwordReminderResendTime' => 43, + ] ) ); + $p->setConfig( $config ); + $this->assertSame( true, $p->emailEnabled ); + $this->assertSame( 42, $p->newPasswordExpiry ); + $this->assertSame( 43, $p->passwordReminderResendTime ); + } + + public function testTestUserCanAuthenticate() { + $user = self::getMutableTestUser()->getUser(); + + $dbw = wfGetDB( DB_MASTER ); + + $passwordFactory = new \PasswordFactory(); + $passwordFactory->init( \RequestContext::getMain()->getConfig() ); + // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only + $passwordFactory->setDefaultType( 'A' ); + $pwhash = $passwordFactory->newFromPlaintext( 'password' )->toString(); + + $provider = $this->getProvider(); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $this->assertFalse( $provider->testUserCanAuthenticate( '' ) ); + $this->assertFalse( $provider->testUserCanAuthenticate( 'DoesNotExist' ) ); + + $dbw->update( + 'user', + [ + 'user_newpassword' => \PasswordFactory::newInvalidPassword()->toString(), + 'user_newpass_time' => null, + ], + [ 'user_id' => $user->getId() ] + ); + $this->assertFalse( $provider->testUserCanAuthenticate( $user->getName() ) ); + + $dbw->update( + 'user', + [ + 'user_newpassword' => $pwhash, + 'user_newpass_time' => null, + ], + [ 'user_id' => $user->getId() ] + ); + $this->assertTrue( $provider->testUserCanAuthenticate( $user->getName() ) ); + $this->assertTrue( $provider->testUserCanAuthenticate( lcfirst( $user->getName() ) ) ); + + $dbw->update( + 'user', + [ + 'user_newpassword' => $pwhash, + 'user_newpass_time' => $dbw->timestamp( time() - 10 ), + ], + [ 'user_id' => $user->getId() ] + ); + $providerPriv->newPasswordExpiry = 100; + $this->assertTrue( $provider->testUserCanAuthenticate( $user->getName() ) ); + $providerPriv->newPasswordExpiry = 1; + $this->assertFalse( $provider->testUserCanAuthenticate( $user->getName() ) ); + + $dbw->update( + 'user', + [ + 'user_newpassword' => \PasswordFactory::newInvalidPassword()->toString(), + 'user_newpass_time' => null, + ], + [ 'user_id' => $user->getId() ] + ); + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param array $options + * @param array $expected + */ + public function testGetAuthenticationRequests( $action, $options, $expected ) { + $actual = $this->getProvider()->getAuthenticationRequests( $action, $options ); + foreach ( $actual as $req ) { + if ( $req instanceof TemporaryPasswordAuthenticationRequest && $req->password !== null ) { + $req->password = 'random'; + } + } + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetAuthenticationRequests() { + $anon = [ 'username' => null ]; + $loggedIn = [ 'username' => 'UTSysop' ]; + + return [ + [ AuthManager::ACTION_LOGIN, $anon, [ + new PasswordAuthenticationRequest + ] ], + [ AuthManager::ACTION_LOGIN, $loggedIn, [ + new PasswordAuthenticationRequest + ] ], + [ AuthManager::ACTION_CREATE, $anon, [] ], + [ AuthManager::ACTION_CREATE, $loggedIn, [ + new TemporaryPasswordAuthenticationRequest( 'random' ) + ] ], + [ AuthManager::ACTION_LINK, $anon, [] ], + [ AuthManager::ACTION_LINK, $loggedIn, [] ], + [ AuthManager::ACTION_CHANGE, $anon, [ + new TemporaryPasswordAuthenticationRequest( 'random' ) + ] ], + [ AuthManager::ACTION_CHANGE, $loggedIn, [ + new TemporaryPasswordAuthenticationRequest( 'random' ) + ] ], + [ AuthManager::ACTION_REMOVE, $anon, [ + new TemporaryPasswordAuthenticationRequest + ] ], + [ AuthManager::ACTION_REMOVE, $loggedIn, [ + new TemporaryPasswordAuthenticationRequest + ] ], + ]; + } + + public function testAuthentication() { + $user = self::getMutableTestUser()->getUser(); + + $password = 'TemporaryPassword'; + $hash = ':A:' . md5( $password ); + $dbw = wfGetDB( DB_MASTER ); + $dbw->update( + 'user', + [ 'user_newpassword' => $hash, 'user_newpass_time' => $dbw->timestamp( time() - 10 ) ], + [ 'user_id' => $user->getId() ] + ); + + $req = new PasswordAuthenticationRequest(); + $req->action = AuthManager::ACTION_LOGIN; + $reqs = [ PasswordAuthenticationRequest::class => $req ]; + + $provider = $this->getProvider(); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + + $providerPriv->newPasswordExpiry = 100; + + // General failures + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( [] ) + ); + + $req->username = 'foo'; + $req->password = null; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = null; + $req->password = 'bar'; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = ''; + $req->password = 'WhoCares'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + $req->username = 'DoesNotExist'; + $req->password = 'DoesNotExist'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAuthentication( $reqs ) + ); + + // Validation failure + $req->username = $user->getName(); + $req->password = $password; + $this->validity = \Status::newFatal( 'arbitrary-failure' ); + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'arbitrary-failure', + $ret->message->getKey() + ); + + // Successful auth + $this->manager->removeAuthenticationSessionData( null ); + $this->validity = \Status::newGood(); + $this->assertEquals( + AuthenticationResponse::newPass( $user->getName() ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + $this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) ); + + $this->manager->removeAuthenticationSessionData( null ); + $this->validity = \Status::newGood(); + $req->username = lcfirst( $user->getName() ); + $this->assertEquals( + AuthenticationResponse::newPass( $user->getName() ), + $provider->beginPrimaryAuthentication( $reqs ) + ); + $this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) ); + $req->username = $user->getName(); + + // Expired password + $providerPriv->newPasswordExpiry = 1; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey() + ); + + // Bad password + $providerPriv->newPasswordExpiry = 100; + $this->validity = \Status::newGood(); + $req->password = 'Wrong'; + $ret = $provider->beginPrimaryAuthentication( $reqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey() + ); + } + + /** + * @dataProvider provideProviderAllowsAuthenticationDataChange + * @param string $type + * @param string $user + * @param \Status $validity Result of the password validity check + * @param \StatusValue $expect1 Expected result with $checkData = false + * @param \StatusValue $expect2 Expected result with $checkData = true + */ + public function testProviderAllowsAuthenticationDataChange( $type, $user, \Status $validity, + \StatusValue $expect1, \StatusValue $expect2 + ) { + if ( $type === PasswordAuthenticationRequest::class || + $type === TemporaryPasswordAuthenticationRequest::class + ) { + $req = new $type(); + } else { + $req = $this->getMock( $type ); + } + $req->action = AuthManager::ACTION_CHANGE; + $req->username = $user; + $req->password = 'NewPassword'; + + $provider = $this->getProvider(); + $this->validity = $validity; + $this->assertEquals( $expect1, $provider->providerAllowsAuthenticationDataChange( $req, false ) ); + $this->assertEquals( $expect2, $provider->providerAllowsAuthenticationDataChange( $req, true ) ); + } + + public static function provideProviderAllowsAuthenticationDataChange() { + $err = \StatusValue::newGood(); + $err->error( 'arbitrary-warning' ); + + return [ + [ AuthenticationRequest::class, 'UTSysop', \Status::newGood(), + \StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ], + [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::newGood(), + \StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ], + [ TemporaryPasswordAuthenticationRequest::class, 'UTSysop', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood() ], + [ TemporaryPasswordAuthenticationRequest::class, 'uTSysop', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood() ], + [ TemporaryPasswordAuthenticationRequest::class, 'UTSysop', \Status::wrap( $err ), + \StatusValue::newGood(), $err ], + [ TemporaryPasswordAuthenticationRequest::class, 'UTSysop', + \Status::newFatal( 'arbitrary-error' ), \StatusValue::newGood(), + \StatusValue::newFatal( 'arbitrary-error' ) ], + [ TemporaryPasswordAuthenticationRequest::class, 'DoesNotExist', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood( 'ignored' ) ], + [ TemporaryPasswordAuthenticationRequest::class, '', \Status::newGood(), + \StatusValue::newGood(), \StatusValue::newGood( 'ignored' ) ], + ]; + } + + /** + * @dataProvider provideProviderChangeAuthenticationData + * @param string $user + * @param string $type + * @param bool $changed + */ + public function testProviderChangeAuthenticationData( $user, $type, $changed ) { + $cuser = ucfirst( $user ); + $oldpass = 'OldTempPassword'; + $newpass = 'NewTempPassword'; + + $dbw = wfGetDB( DB_MASTER ); + $oldHash = $dbw->selectField( 'user', 'user_newpassword', [ 'user_name' => $cuser ] ); + $cb = new ScopedCallback( function () use ( $dbw, $cuser, $oldHash ) { + $dbw->update( 'user', [ 'user_newpassword' => $oldHash ], [ 'user_name' => $cuser ] ); + } ); + + $hash = ':A:' . md5( $oldpass ); + $dbw->update( + 'user', + [ 'user_newpassword' => $hash, 'user_newpass_time' => $dbw->timestamp( time() + 10 ) ], + [ 'user_name' => $cuser ] + ); + + $provider = $this->getProvider(); + + // Sanity check + $loginReq = new PasswordAuthenticationRequest(); + $loginReq->action = AuthManager::ACTION_CHANGE; + $loginReq->username = $user; + $loginReq->password = $oldpass; + $loginReqs = [ PasswordAuthenticationRequest::class => $loginReq ]; + $this->assertEquals( + AuthenticationResponse::newPass( $cuser ), + $provider->beginPrimaryAuthentication( $loginReqs ), + 'Sanity check' + ); + + if ( $type === PasswordAuthenticationRequest::class || + $type === TemporaryPasswordAuthenticationRequest::class + ) { + $changeReq = new $type(); + } else { + $changeReq = $this->getMock( $type ); + } + $changeReq->action = AuthManager::ACTION_CHANGE; + $changeReq->username = $user; + $changeReq->password = $newpass; + $resetMailer = $this->hookMailer(); + $provider->providerChangeAuthenticationData( $changeReq ); + ScopedCallback::consume( $resetMailer ); + + $loginReq->password = $oldpass; + $ret = $provider->beginPrimaryAuthentication( $loginReqs ); + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status, + 'old password should fail' + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey(), + 'old password should fail' + ); + + $loginReq->password = $newpass; + $ret = $provider->beginPrimaryAuthentication( $loginReqs ); + if ( $changed ) { + $this->assertEquals( + AuthenticationResponse::newPass( $cuser ), + $ret, + 'new password should pass' + ); + $this->assertNotNull( + $dbw->selectField( 'user', 'user_newpass_time', [ 'user_name' => $cuser ] ) + ); + } else { + $this->assertEquals( + AuthenticationResponse::FAIL, + $ret->status, + 'new password should fail' + ); + $this->assertEquals( + 'wrongpassword', + $ret->message->getKey(), + 'new password should fail' + ); + $this->assertNull( + $dbw->selectField( 'user', 'user_newpass_time', [ 'user_name' => $cuser ] ) + ); + } + } + + public static function provideProviderChangeAuthenticationData() { + return [ + [ 'UTSysop', AuthenticationRequest::class, false ], + [ 'UTSysop', PasswordAuthenticationRequest::class, false ], + [ 'UTSysop', TemporaryPasswordAuthenticationRequest::class, true ], + ]; + } + + public function testProviderChangeAuthenticationDataEmail() { + $user = self::getMutableTestUser()->getUser(); + + $dbw = wfGetDB( DB_MASTER ); + $dbw->update( + 'user', + [ 'user_newpass_time' => $dbw->timestamp( time() - 5 * 3600 ) ], + [ 'user_id' => $user->getId() ] + ); + + $req = TemporaryPasswordAuthenticationRequest::newRandom(); + $req->username = $user->getName(); + $req->mailpassword = true; + + $provider = $this->getProvider( [ 'emailEnabled' => false ] ); + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newFatal( 'passwordreset-emaildisabled' ), $status ); + + $provider = $this->getProvider( [ 'passwordReminderResendTime' => 10 ] ); + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newFatal( 'throttled-mailpassword', 10 ), $status ); + + $provider = $this->getProvider( [ 'passwordReminderResendTime' => 3 ] ); + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertFalse( $status->hasMessage( 'throttled-mailpassword' ) ); + + $dbw->update( + 'user', + [ 'user_newpass_time' => $dbw->timestamp( time() + 5 * 3600 ) ], + [ 'user_id' => $user->getId() ] + ); + $provider = $this->getProvider( [ 'passwordReminderResendTime' => 0 ] ); + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertFalse( $status->hasMessage( 'throttled-mailpassword' ) ); + + $req->caller = null; + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newFatal( 'passwordreset-nocaller' ), $status ); + + $req->caller = '127.0.0.256'; + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newFatal( 'passwordreset-nosuchcaller', '127.0.0.256' ), + $status ); + + $req->caller = ''; + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newFatal( 'passwordreset-nosuchcaller', '' ), + $status ); + + $req->caller = '127.0.0.1'; + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newGood(), $status ); + + $req->caller = $user->getName(); + $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); + $this->assertEquals( \StatusValue::newGood(), $status ); + + $mailed = false; + $resetMailer = $this->hookMailer( function ( $headers, $to, $from, $subject, $body ) + use ( &$mailed, $req, $user ) + { + $mailed = true; + $this->assertSame( $user->getEmail(), $to[0]->address ); + $this->assertContains( $req->password, $body ); + return false; + } ); + $provider->providerChangeAuthenticationData( $req ); + ScopedCallback::consume( $resetMailer ); + $this->assertTrue( $mailed ); + + $priv = \TestingAccessWrapper::newFromObject( $provider ); + $req->username = ''; + $status = $priv->sendPasswordResetEmail( $req ); + $this->assertEquals( \Status::newFatal( 'noname' ), $status ); + } + + public function testTestForAccountCreation() { + $user = \User::newFromName( 'foo' ); + $req = new TemporaryPasswordAuthenticationRequest(); + $req->username = 'Foo'; + $req->password = 'Bar'; + $reqs = [ TemporaryPasswordAuthenticationRequest::class => $req ]; + + $provider = $this->getProvider(); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ), + 'No password request' + ); + + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, $reqs ), + 'Password request, validated' + ); + + $this->validity->error( 'arbitrary warning' ); + $expect = \StatusValue::newGood(); + $expect->error( 'arbitrary warning' ); + $this->assertEquals( + $expect, + $provider->testForAccountCreation( $user, $user, $reqs ), + 'Password request, not validated' + ); + } + + public function testAccountCreation() { + $resetMailer = $this->hookMailer(); + + $user = \User::newFromName( 'Foo' ); + + $req = new TemporaryPasswordAuthenticationRequest(); + $reqs = [ TemporaryPasswordAuthenticationRequest::class => $req ]; + + $authreq = new PasswordAuthenticationRequest(); + $authreq->action = AuthManager::ACTION_CREATE; + $authreqs = [ PasswordAuthenticationRequest::class => $authreq ]; + + $provider = $this->getProvider(); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, [] ) + ); + + $req->username = 'foo'; + $req->password = null; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $req->username = null; + $req->password = 'bar'; + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) + ); + + $req->username = 'foo'; + $req->password = 'bar'; + + $expect = AuthenticationResponse::newPass( 'Foo' ); + $expect->createRequest = clone( $req ); + $expect->createRequest->username = 'Foo'; + $this->assertEquals( $expect, $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) ); + $this->assertNull( $this->manager->getAuthenticationSessionData( 'no-email' ) ); + + $user = self::getMutableTestUser()->getUser(); + $req->username = $authreq->username = $user->getName(); + $req->password = $authreq->password = 'NewPassword'; + $expect = AuthenticationResponse::newPass( $user->getName() ); + $expect->createRequest = $req; + + $res2 = $provider->beginPrimaryAccountCreation( $user, $user, $reqs ); + $this->assertEquals( $expect, $res2, 'Sanity check' ); + + $ret = $provider->beginPrimaryAuthentication( $authreqs ); + $this->assertEquals( AuthenticationResponse::FAIL, $ret->status, 'sanity check' ); + + $this->assertSame( null, $provider->finishAccountCreation( $user, $user, $res2 ) ); + + $ret = $provider->beginPrimaryAuthentication( $authreqs ); + $this->assertEquals( AuthenticationResponse::PASS, $ret->status, 'new password is set' ); + } + + public function testAccountCreationEmail() { + $creator = \User::newFromName( 'Foo' ); + + $user = self::getMutableTestUser()->getUser(); + $user->setEmail( null ); + + $req = TemporaryPasswordAuthenticationRequest::newRandom(); + $req->username = $user->getName(); + $req->mailpassword = true; + + $provider = $this->getProvider( [ 'emailEnabled' => false ] ); + $status = $provider->testForAccountCreation( $user, $creator, [ $req ] ); + $this->assertEquals( \StatusValue::newFatal( 'emaildisabled' ), $status ); + + $provider = $this->getProvider( [ 'emailEnabled' => true ] ); + $status = $provider->testForAccountCreation( $user, $creator, [ $req ] ); + $this->assertEquals( \StatusValue::newFatal( 'noemailcreate' ), $status ); + + $user->setEmail( 'test@localhost.localdomain' ); + $status = $provider->testForAccountCreation( $user, $creator, [ $req ] ); + $this->assertEquals( \StatusValue::newGood(), $status ); + + $mailed = false; + $resetMailer = $this->hookMailer( function ( $headers, $to, $from, $subject, $body ) + use ( &$mailed, $req ) + { + $mailed = true; + $this->assertSame( 'test@localhost.localdomain', $to[0]->address ); + $this->assertContains( $req->password, $body ); + return false; + } ); + + $expect = AuthenticationResponse::newPass( $user->getName() ); + $expect->createRequest = clone( $req ); + $expect->createRequest->username = $user->getName(); + $res = $provider->beginPrimaryAccountCreation( $user, $creator, [ $req ] ); + $this->assertEquals( $expect, $res ); + $this->assertTrue( $this->manager->getAuthenticationSessionData( 'no-email' ) ); + $this->assertFalse( $mailed ); + + $this->assertSame( 'byemail', $provider->finishAccountCreation( $user, $creator, $res ) ); + $this->assertTrue( $mailed ); + + ScopedCallback::consume( $resetMailer ); + $this->assertTrue( $mailed ); + } + +} diff --git a/tests/phpunit/includes/auth/ThrottlePreAuthenticationProviderTest.php b/tests/phpunit/includes/auth/ThrottlePreAuthenticationProviderTest.php new file mode 100644 index 0000000000..20f4cbc44d --- /dev/null +++ b/tests/phpunit/includes/auth/ThrottlePreAuthenticationProviderTest.php @@ -0,0 +1,232 @@ + [ [ + 'count' => 123, + 'seconds' => 86400, + ] ], + 'PasswordAttemptThrottle' => [ [ + 'count' => 5, + 'seconds' => 300, + ] ], + ] ); + $provider->setConfig( $config ); + $this->assertSame( [ + 'accountCreationThrottle' => [ [ 'count' => 123, 'seconds' => 86400 ] ], + 'passwordAttemptThrottle' => [ [ 'count' => 5, 'seconds' => 300 ] ] + ], $providerPriv->throttleSettings ); + $accountCreationThrottle = \TestingAccessWrapper::newFromObject( + $providerPriv->accountCreationThrottle ); + $this->assertSame( [ [ 'count' => 123, 'seconds' => 86400 ] ], + $accountCreationThrottle->conditions ); + $passwordAttemptThrottle = \TestingAccessWrapper::newFromObject( + $providerPriv->passwordAttemptThrottle ); + $this->assertSame( [ [ 'count' => 5, 'seconds' => 300 ] ], + $passwordAttemptThrottle->conditions ); + + $provider = new ThrottlePreAuthenticationProvider( [ + 'accountCreationThrottle' => [ [ 'count' => 43, 'seconds' => 10000 ] ], + 'passwordAttemptThrottle' => [ [ 'count' => 11, 'seconds' => 100 ] ], + ] ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + $config = new \HashConfig( [ + 'AccountCreationThrottle' => [ [ + 'count' => 123, + 'seconds' => 86400, + ] ], + 'PasswordAttemptThrottle' => [ [ + 'count' => 5, + 'seconds' => 300, + ] ], + ] ); + $provider->setConfig( $config ); + $this->assertSame( [ + 'accountCreationThrottle' => [ [ 'count' => 43, 'seconds' => 10000 ] ], + 'passwordAttemptThrottle' => [ [ 'count' => 11, 'seconds' => 100 ] ], + ], $providerPriv->throttleSettings ); + + $cache = new \HashBagOStuff(); + $provider = new ThrottlePreAuthenticationProvider( [ 'cache' => $cache ] ); + $providerPriv = \TestingAccessWrapper::newFromObject( $provider ); + $provider->setConfig( new \HashConfig( [ + 'AccountCreationThrottle' => [ [ 'count' => 1, 'seconds' => 1 ] ], + 'PasswordAttemptThrottle' => [ [ 'count' => 1, 'seconds' => 1 ] ], + ] ) ); + $accountCreationThrottle = \TestingAccessWrapper::newFromObject( + $providerPriv->accountCreationThrottle ); + $this->assertSame( $cache, $accountCreationThrottle->cache ); + $passwordAttemptThrottle = \TestingAccessWrapper::newFromObject( + $providerPriv->passwordAttemptThrottle ); + $this->assertSame( $cache, $passwordAttemptThrottle->cache ); + } + + public function testDisabled() { + $provider = new ThrottlePreAuthenticationProvider( [ + 'accountCreationThrottle' => [], + 'passwordAttemptThrottle' => [], + 'cache' => new \HashBagOStuff(), + ] ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( new \HashConfig( [ + 'AccountCreationThrottle' => null, + 'PasswordAttemptThrottle' => null, + ] ) ); + $provider->setManager( AuthManager::singleton() ); + + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( + \User::newFromName( 'Created' ), + \User::newFromName( 'Creator' ), + [] + ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAuthentication( [] ) + ); + } + + /** + * @dataProvider provideTestForAccountCreation + * @param string $creatorname + * @param bool $succeed + * @param bool $hook + */ + public function testTestForAccountCreation( $creatorname, $succeed, $hook ) { + $provider = new ThrottlePreAuthenticationProvider( [ + 'accountCreationThrottle' => [ [ 'count' => 2, 'seconds' => 86400 ] ], + 'cache' => new \HashBagOStuff(), + ] ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( new \HashConfig( [ + 'AccountCreationThrottle' => null, + 'PasswordAttemptThrottle' => null, + ] ) ); + $provider->setManager( AuthManager::singleton() ); + + $user = \User::newFromName( 'RandomUser' ); + $creator = \User::newFromName( $creatorname ); + if ( $hook ) { + $mock = $this->getMock( 'stdClass', [ 'onExemptFromAccountCreationThrottle' ] ); + $mock->expects( $this->any() )->method( 'onExemptFromAccountCreationThrottle' ) + ->will( $this->returnValue( false ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + 'ExemptFromAccountCreationThrottle' => [ $mock ], + ] ); + } + + $this->assertEquals( + true, + $provider->testForAccountCreation( $user, $creator, [] )->isOK(), + 'attempt #1' + ); + $this->assertEquals( + true, + $provider->testForAccountCreation( $user, $creator, [] )->isOK(), + 'attempt #2' + ); + $this->assertEquals( + $succeed ? true : false, + $provider->testForAccountCreation( $user, $creator, [] )->isOK(), + 'attempt #3' + ); + } + + public static function provideTestForAccountCreation() { + return [ + 'Normal user' => [ 'NormalUser', false, false ], + 'Sysop' => [ 'UTSysop', true, false ], + 'Normal user with hook' => [ 'NormalUser', true, true ], + ]; + } + + public function testTestForAuthentication() { + $provider = new ThrottlePreAuthenticationProvider( [ + 'passwordAttemptThrottle' => [ [ 'count' => 2, 'seconds' => 86400 ] ], + 'cache' => new \HashBagOStuff(), + ] ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( new \HashConfig( [ + 'AccountCreationThrottle' => null, + 'PasswordAttemptThrottle' => null, + ] ) ); + $provider->setManager( AuthManager::singleton() ); + + $req = new UsernameAuthenticationRequest; + $req->username = 'SomeUser'; + for ( $i = 1; $i <= 3; $i++ ) { + $status = $provider->testForAuthentication( [ $req ] ); + $this->assertEquals( $i < 3, $status->isGood(), "attempt #$i" ); + } + $this->assertCount( 1, $status->getErrors() ); + $msg = new \Message( $status->getErrors()[0]['message'], $status->getErrors()[0]['params'] ); + $this->assertEquals( 'login-throttled', $msg->getKey() ); + + $provider->postAuthentication( \User::newFromName( 'SomeUser' ), + AuthenticationResponse::newFail( wfMessage( 'foo' ) ) ); + $this->assertFalse( $provider->testForAuthentication( [ $req ] )->isGood(), 'after FAIL' ); + + $provider->postAuthentication( \User::newFromName( 'SomeUser' ), + AuthenticationResponse::newPass() ); + $this->assertTrue( $provider->testForAuthentication( [ $req ] )->isGood(), 'after PASS' ); + + $req1 = new UsernameAuthenticationRequest; + $req1->username = 'foo'; + $req2 = new UsernameAuthenticationRequest; + $req2->username = 'bar'; + $this->assertTrue( $provider->testForAuthentication( [ $req1, $req2 ] )->isGood() ); + + $req = new UsernameAuthenticationRequest; + $req->username = 'Some user'; + $provider->testForAuthentication( [ $req ] ); + $req->username = 'Some_user'; + $provider->testForAuthentication( [ $req ] ); + $req->username = 'some user'; + $status = $provider->testForAuthentication( [ $req ] ); + $this->assertFalse( $status->isGood(), 'denormalized usernames are normalized' ); + } + + public function testPostAuthentication() { + $provider = new ThrottlePreAuthenticationProvider( [ + 'passwordAttemptThrottle' => [], + 'cache' => new \HashBagOStuff(), + ] ); + $provider->setLogger( new \TestLogger ); + $provider->setConfig( new \HashConfig( [ + 'AccountCreationThrottle' => null, + 'PasswordAttemptThrottle' => null, + ] ) ); + $provider->setManager( AuthManager::singleton() ); + $provider->postAuthentication( \User::newFromName( 'SomeUser' ), + AuthenticationResponse::newPass() ); + + $provider = new ThrottlePreAuthenticationProvider( [ + 'passwordAttemptThrottle' => [ [ 'count' => 2, 'seconds' => 86400 ] ], + 'cache' => new \HashBagOStuff(), + ] ); + $logger = new \TestLogger( true ); + $provider->setLogger( $logger ); + $provider->setConfig( new \HashConfig( [ + 'AccountCreationThrottle' => null, + 'PasswordAttemptThrottle' => null, + ] ) ); + $provider->setManager( AuthManager::singleton() ); + $provider->postAuthentication( \User::newFromName( 'SomeUser' ), + AuthenticationResponse::newPass() ); + $this->assertSame( [ + [ \Psr\Log\LogLevel::ERROR, 'throttler data not found for {user}' ], + ], $logger->getBuffer() ); + } +} diff --git a/tests/phpunit/includes/auth/ThrottlerTest.php b/tests/phpunit/includes/auth/ThrottlerTest.php new file mode 100644 index 0000000000..c945885c76 --- /dev/null +++ b/tests/phpunit/includes/auth/ThrottlerTest.php @@ -0,0 +1,237 @@ +getMockBuilder( AbstractLogger::class ) + ->setMethods( [ 'log' ] ) + ->getMockForAbstractClass(); + + $throttler = new Throttler( + [ [ 'count' => 123, 'seconds' => 456 ] ], + [ 'type' => 'foo', 'cache' => $cache ] + ); + $throttler->setLogger( $logger ); + $throttlerPriv = \TestingAccessWrapper::newFromObject( $throttler ); + $this->assertSame( [ [ 'count' => 123, 'seconds' => 456 ] ], $throttlerPriv->conditions ); + $this->assertSame( 'foo', $throttlerPriv->type ); + $this->assertSame( $cache, $throttlerPriv->cache ); + $this->assertSame( $logger, $throttlerPriv->logger ); + + $throttler = new Throttler( [ [ 'count' => 123, 'seconds' => 456 ] ] ); + $throttler->setLogger( new NullLogger() ); + $throttlerPriv = \TestingAccessWrapper::newFromObject( $throttler ); + $this->assertSame( [ [ 'count' => 123, 'seconds' => 456 ] ], $throttlerPriv->conditions ); + $this->assertSame( 'custom', $throttlerPriv->type ); + $this->assertInstanceOf( BagOStuff::class, $throttlerPriv->cache ); + $this->assertInstanceOf( LoggerInterface::class, $throttlerPriv->logger ); + + $this->setMwGlobals( [ 'wgPasswordAttemptThrottle' => [ [ 'count' => 321, + 'seconds' => 654 ] ] ] ); + $throttler = new Throttler(); + $throttler->setLogger( new NullLogger() ); + $throttlerPriv = \TestingAccessWrapper::newFromObject( $throttler ); + $this->assertSame( [ [ 'count' => 321, 'seconds' => 654 ] ], $throttlerPriv->conditions ); + $this->assertSame( 'password', $throttlerPriv->type ); + $this->assertInstanceOf( BagOStuff::class, $throttlerPriv->cache ); + $this->assertInstanceOf( LoggerInterface::class, $throttlerPriv->logger ); + + try { + new Throttler( [], [ 'foo' => 1, 'bar' => 2, 'baz' => 3 ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'unrecognized parameters: foo, bar, baz', $ex->getMessage() ); + } + } + + /** + * @dataProvider provideNormalizeThrottleConditions + */ + public function testNormalizeThrottleConditions( $condition, $normalized ) { + $throttler = new Throttler( $condition ); + $throttler->setLogger( new NullLogger() ); + $throttlerPriv = \TestingAccessWrapper::newFromObject( $throttler ); + $this->assertSame( $normalized, $throttlerPriv->conditions ); + } + + public function provideNormalizeThrottleConditions() { + return [ + [ + [], + [], + ], + [ + [ 'count' => 1, 'seconds' => 2 ], + [ [ 'count' => 1, 'seconds' => 2 ] ], + ], + [ + [ [ 'count' => 1, 'seconds' => 2 ], [ 'count' => 2, 'seconds' => 3 ] ], + [ [ 'count' => 1, 'seconds' => 2 ], [ 'count' => 2, 'seconds' => 3 ] ], + ], + ]; + } + + public function testNormalizeThrottleConditions2() { + $priv = \TestingAccessWrapper::newFromClass( Throttler::class ); + $this->assertSame( [], $priv->normalizeThrottleConditions( null ) ); + $this->assertSame( [], $priv->normalizeThrottleConditions( 'bad' ) ); + } + + public function testIncrease() { + $cache = new \HashBagOStuff(); + $throttler = new Throttler( [ + [ 'count' => 2, 'seconds' => 10, ], + [ 'count' => 4, 'seconds' => 15, 'allIPs' => true ], + ], [ 'cache' => $cache ] ); + $throttler->setLogger( new NullLogger() ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertSame( [ 'throttleIndex' => 0, 'count' => 2, 'wait' => 10 ], $result ); + + $result = $throttler->increase( 'OtherUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'SomeUser', '2.3.4.5' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'SomeUser', '3.4.5.6' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'SomeUser', '3.4.5.6' ); + $this->assertSame( [ 'throttleIndex' => 1, 'count' => 4, 'wait' => 15 ], $result ); + } + + public function testZeroCount() { + $cache = new \HashBagOStuff(); + $throttler = new Throttler( [ [ 'count' => 0, 'seconds' => 10 ] ], [ 'cache' => $cache ] ); + $throttler->setLogger( new NullLogger() ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle, count=0 is ignored' ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle, count=0 is ignored' ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle, count=0 is ignored' ); + } + + public function testNamespacing() { + $cache = new \HashBagOStuff(); + $throttler1 = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], + [ 'cache' => $cache, 'type' => 'foo' ] ); + $throttler2 = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], + [ 'cache' => $cache, 'type' => 'foo' ] ); + $throttler3 = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], + [ 'cache' => $cache, 'type' => 'bar' ] ); + $throttler1->setLogger( new NullLogger() ); + $throttler2->setLogger( new NullLogger() ); + $throttler3->setLogger( new NullLogger() ); + + $throttled = [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ]; + + $result = $throttler1->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler1->increase( 'SomeUser', '1.2.3.4' ); + $this->assertEquals( $throttled, $result, 'should throttle' ); + + $result = $throttler2->increase( 'SomeUser', '1.2.3.4' ); + $this->assertEquals( $throttled, $result, 'should throttle, same namespace' ); + + $result = $throttler3->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle, different namespace' ); + } + + public function testExpiration() { + $cache = $this->getMock( HashBagOStuff::class, [ 'add' ] ); + $throttler = new Throttler( [ [ 'count' => 3, 'seconds' => 10 ] ], [ 'cache' => $cache ] ); + $throttler->setLogger( new NullLogger() ); + + $cache->expects( $this->once() )->method( 'add' )->with( $this->anything(), 1, 10 ); + $throttler->increase( 'SomeUser' ); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testException() { + $throttler = new Throttler( [ [ 'count' => 3, 'seconds' => 10 ] ] ); + $throttler->setLogger( new NullLogger() ); + $throttler->increase(); + } + + public function testLog() { + $cache = new \HashBagOStuff(); + $throttler = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], [ 'cache' => $cache ] ); + + $logger = $this->getMockBuilder( AbstractLogger::class ) + ->setMethods( [ 'log' ] ) + ->getMockForAbstractClass(); + $logger->expects( $this->never() )->method( 'log' ); + $throttler->setLogger( $logger ); + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $logger = $this->getMockBuilder( AbstractLogger::class ) + ->setMethods( [ 'log' ] ) + ->getMockForAbstractClass(); + $logger->expects( $this->once() )->method( 'log' )->with( $this->anything(), $this->anything(), [ + 'throttle' => 'custom', + 'index' => 0, + 'ip' => '1.2.3.4', + 'username' => 'SomeUser', + 'count' => 1, + 'expiry' => 10, + 'method' => 'foo', + ] ); + $throttler->setLogger( $logger ); + $result = $throttler->increase( 'SomeUser', '1.2.3.4', 'foo' ); + $this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result ); + } + + public function testClear() { + $cache = new \HashBagOStuff(); + $throttler = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], [ 'cache' => $cache ] ); + $throttler->setLogger( new NullLogger() ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result ); + + $result = $throttler->increase( 'OtherUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'OtherUser', '1.2.3.4' ); + $this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result ); + + $throttler->clear( 'SomeUser', '1.2.3.4' ); + + $result = $throttler->increase( 'SomeUser', '1.2.3.4' ); + $this->assertFalse( $result, 'should not throttle' ); + + $result = $throttler->increase( 'OtherUser', '1.2.3.4' ); + $this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result ); + } +} diff --git a/tests/phpunit/includes/auth/UserDataAuthenticationRequestTest.php b/tests/phpunit/includes/auth/UserDataAuthenticationRequestTest.php new file mode 100644 index 0000000000..7dea123c12 --- /dev/null +++ b/tests/phpunit/includes/auth/UserDataAuthenticationRequestTest.php @@ -0,0 +1,176 @@ +setMwGlobals( 'wgHiddenPrefs', [] ); + } + + /** + * @dataProvider providePopulateUser + * @param string $email Email to set + * @param string $realname Realname to set + * @param StatusValue $expect Expected return + */ + public function testPopulateUser( $email, $realname, $expect ) { + $user = new \User(); + $user->setEmail( 'default@example.com' ); + $user->setRealName( 'Fake Name' ); + + $req = new UserDataAuthenticationRequest; + $req->email = $email; + $req->realname = $realname; + $this->assertEquals( $expect, $req->populateUser( $user ) ); + if ( $expect->isOk() ) { + $this->assertSame( $email ?: 'default@example.com', $user->getEmail() ); + $this->assertSame( $realname ?: 'Fake Name', $user->getRealName() ); + } + } + + public static function providePopulateUser() { + $good = \StatusValue::newGood(); + return [ + [ 'email@example.com', 'Real Name', $good ], + [ 'email@example.com', '', $good ], + [ '', 'Real Name', $good ], + [ '', '', $good ], + [ 'invalid-email', 'Real Name', \StatusValue::newFatal( 'invalidemailaddress' ) ], + ]; + } + + /** + * @dataProvider provideLoadFromSubmission + */ + public function testLoadFromSubmission( + array $args, array $data, $expectState /* $hiddenPref, $enableEmail */ + ) { + list( $args, $data, $expectState, $hiddenPref, $enableEmail ) = func_get_args(); + $this->setMwGlobals( 'wgHiddenPrefs', $hiddenPref ); + $this->setMwGlobals( 'wgEnableEmail', $enableEmail ); + parent::testLoadFromSubmission( $args, $data, $expectState ); + } + + public function provideLoadFromSubmission() { + $unhidden = []; + $hidden = [ 'realname' ]; + + return [ + 'Empty request, unhidden, email enabled' => [ + [], + [], + false, + $unhidden, + true + ], + 'email + realname, unhidden, email enabled' => [ + [], + $data = [ 'email' => 'Email', 'realname' => 'Name' ], + $data, + $unhidden, + true + ], + 'email empty, unhidden, email enabled' => [ + [], + $data = [ 'email' => '', 'realname' => 'Name' ], + $data, + $unhidden, + true + ], + 'email omitted, unhidden, email enabled' => [ + [], + [ 'realname' => 'Name' ], + false, + $unhidden, + true + ], + 'realname empty, unhidden, email enabled' => [ + [], + $data = [ 'email' => 'Email', 'realname' => '' ], + $data, + $unhidden, + true + ], + 'realname omitted, unhidden, email enabled' => [ + [], + [ 'email' => 'Email' ], + false, + $unhidden, + true + ], + 'Empty request, hidden, email enabled' => [ + [], + [], + false, + $hidden, + true + ], + 'email + realname, hidden, email enabled' => [ + [], + [ 'email' => 'Email', 'realname' => 'Name' ], + [ 'email' => 'Email' ], + $hidden, + true + ], + 'email empty, hidden, email enabled' => [ + [], + $data = [ 'email' => '', 'realname' => 'Name' ], + [ 'email' => '' ], + $hidden, + true + ], + 'email omitted, hidden, email enabled' => [ + [], + [ 'realname' => 'Name' ], + false, + $hidden, + true + ], + 'realname empty, hidden, email enabled' => [ + [], + $data = [ 'email' => 'Email', 'realname' => '' ], + [ 'email' => 'Email' ], + $hidden, + true + ], + 'realname omitted, hidden, email enabled' => [ + [], + [ 'email' => 'Email' ], + [ 'email' => 'Email' ], + $hidden, + true + ], + 'email + realname, unhidden, email disabled' => [ + [], + [ 'email' => 'Email', 'realname' => 'Name' ], + [ 'realname' => 'Name' ], + $unhidden, + false + ], + 'email omitted, unhidden, email disabled' => [ + [], + [ 'realname' => 'Name' ], + [ 'realname' => 'Name' ], + $unhidden, + false + ], + 'email empty, unhidden, email disabled' => [ + [], + [ 'email' => '', 'realname' => 'Name' ], + [ 'realname' => 'Name' ], + $unhidden, + false + ], + ]; + } +} diff --git a/tests/phpunit/includes/auth/UsernameAuthenticationRequestTest.php b/tests/phpunit/includes/auth/UsernameAuthenticationRequestTest.php new file mode 100644 index 0000000000..63628dd89e --- /dev/null +++ b/tests/phpunit/includes/auth/UsernameAuthenticationRequestTest.php @@ -0,0 +1,34 @@ + [ + [], + [], + false + ], + 'Username' => [ + [], + $data = [ 'username' => 'User' ], + $data, + ], + 'Username empty' => [ + [], + [ 'username' => '' ], + false + ], + ]; + } +} diff --git a/tests/phpunit/includes/cache/GenderCacheTest.php b/tests/phpunit/includes/cache/GenderCacheTest.php index e329f8d8c5..e5bb2379ef 100644 --- a/tests/phpunit/includes/cache/GenderCacheTest.php +++ b/tests/phpunit/includes/cache/GenderCacheTest.php @@ -1,4 +1,5 @@ username */ + private static $nameMap; + function addDBDataOnce() { // ensure the correct default gender $this->mergeMwGlobalArrayValue( 'wgDefaultUserOptions', [ 'gender' => 'unknown' ] ); - $user = User::newFromName( 'UTMale' ); - if ( $user->getId() == 0 ) { - $user->addToDatabase(); - TestUser::setPasswordForUser( $user, 'UTMalePassword' ); - } - // ensure the right gender - $user->setOption( 'gender', 'male' ); - $user->saveSettings(); + $male = $this->getMutableTestUser()->getUser(); + $male->setOption( 'gender', 'male' ); + $male->saveSettings(); + + $female = $this->getMutableTestUser()->getUser(); + $female->setOption( 'gender', 'female' ); + $female->saveSettings(); - $user = User::newFromName( 'UTFemale' ); - if ( $user->getId() == 0 ) { - $user->addToDatabase(); - TestUser::setPasswordForUser( $user, 'UTFemalePassword' ); - } - // ensure the right gender - $user->setOption( 'gender', 'female' ); - $user->saveSettings(); + $default = $this->getMutableTestUser()->getUser(); + $default->setOption( 'gender', null ); + $default->saveSettings(); - $user = User::newFromName( 'UTDefaultGender' ); - if ( $user->getId() == 0 ) { - $user->addToDatabase(); - TestUser::setPasswordForUser( $user, 'UTDefaultGenderPassword' ); - } - // ensure the default gender - $user->setOption( 'gender', null ); - $user->saveSettings(); + self::$nameMap = [ + 'UTMale' => $male->getName(), + 'UTFemale' => $female->getName(), + 'UTDefaultGender' => $default->getName() + ]; } /** @@ -44,8 +39,9 @@ class GenderCacheTest extends MediaWikiLangTestCase { * @dataProvider provideUserGenders * @covers GenderCache::getGenderOf */ - public function testUserName( $username, $expectedGender ) { - $genderCache = GenderCache::singleton(); + public function testUserName( $userKey, $expectedGender ) { + $genderCache = MediaWikiServices::getInstance()->getGenderCache(); + $username = isset( self::$nameMap[$userKey] ) ? self::$nameMap[$userKey] : $userKey; $gender = $genderCache->getGenderOf( $username ); $this->assertEquals( $gender, $expectedGender, "GenderCache normal" ); } @@ -56,10 +52,10 @@ class GenderCacheTest extends MediaWikiLangTestCase { * @dataProvider provideUserGenders * @covers GenderCache::getGenderOf */ - public function testUserObjects( $username, $expectedGender ) { - $genderCache = GenderCache::singleton(); - $user = User::newFromName( $username ); - $gender = $genderCache->getGenderOf( $user ); + public function testUserObjects( $userKey, $expectedGender ) { + $username = isset( self::$nameMap[$userKey] ) ? self::$nameMap[$userKey] : $userKey; + $genderCache = MediaWikiServices::getInstance()->getGenderCache(); + $gender = $genderCache->getGenderOf( $username ); $this->assertEquals( $gender, $expectedGender, "GenderCache normal" ); } @@ -79,22 +75,13 @@ class GenderCacheTest extends MediaWikiLangTestCase { * test strip of subpages to avoid unnecessary queries * against the never existing username * - * @dataProvider provideStripSubpages + * @dataProvider provideUserGenders * @covers GenderCache::getGenderOf */ - public function testStripSubpages( $pageWithSubpage, $expectedGender ) { - $genderCache = GenderCache::singleton(); - $gender = $genderCache->getGenderOf( $pageWithSubpage ); + public function testStripSubpages( $userKey, $expectedGender ) { + $username = isset( self::$nameMap[$userKey] ) ? self::$nameMap[$userKey] : $userKey; + $genderCache = MediaWikiServices::getInstance()->getGenderCache(); + $gender = $genderCache->getGenderOf( "$username/subpage" ); $this->assertEquals( $gender, $expectedGender, "GenderCache must strip of subpages" ); } - - public static function provideStripSubpages() { - return [ - [ 'UTMale/subpage', 'male' ], - [ 'UTFemale/subpage', 'female' ], - [ 'UTDefaultGender/subpage', 'unknown' ], - [ 'UTNotExist/subpage', 'unknown' ], - [ '127.0.0.1/subpage', 'unknown' ], - ]; - } } diff --git a/tests/phpunit/includes/cache/LocalisationCacheTest.php b/tests/phpunit/includes/cache/LocalisationCacheTest.php index 697eb2dc81..ed821530a2 100644 --- a/tests/phpunit/includes/cache/LocalisationCacheTest.php +++ b/tests/phpunit/includes/cache/LocalisationCacheTest.php @@ -61,14 +61,14 @@ class LocalisationCacheTest extends MediaWikiTestCase { public function testRecacheFallbacks() { $lc = $this->getMockLocalisationCache(); - $lc->recache( 'uk' ); + $lc->recache( 'ba' ); $this->assertEquals( [ - 'present-uk' => 'uk', + 'present-ba' => 'ba', 'present-ru' => 'ru', 'present-en' => 'en', ], - $lc->getItem( 'uk', 'messages' ), + $lc->getItem( 'ba', 'messages' ), 'Fallbacks are only used to fill missing data' ); } @@ -84,7 +84,7 @@ class LocalisationCacheTest extends MediaWikiTestCase { array &$cache ) { if ( $code === 'ru' ) { - $cache['messages']['present-uk'] = 'ru-override'; + $cache['messages']['present-ba'] = 'ru-override'; $cache['messages']['present-ru'] = 'ru-override'; $cache['messages']['present-en'] = 'ru-override'; } @@ -93,14 +93,14 @@ class LocalisationCacheTest extends MediaWikiTestCase { ] ); $lc = $this->getMockLocalisationCache(); - $lc->recache( 'uk' ); + $lc->recache( 'ba' ); $this->assertEquals( [ - 'present-uk' => 'uk', + 'present-ba' => 'ba', 'present-ru' => 'ru-override', 'present-en' => 'ru-override', ], - $lc->getItem( 'uk', 'messages' ), + $lc->getItem( 'ba', 'messages' ), 'Updates provided by hooks follow the normal fallback order.' ); } diff --git a/tests/phpunit/includes/changes/CategoryMembershipChangeTest.php b/tests/phpunit/includes/changes/CategoryMembershipChangeTest.php index 1d86fb4005..e44de099d5 100644 --- a/tests/phpunit/includes/changes/CategoryMembershipChangeTest.php +++ b/tests/phpunit/includes/changes/CategoryMembershipChangeTest.php @@ -29,6 +29,11 @@ class CategoryMembershipChangeTest extends MediaWikiLangTestCase { */ private static $pageRev = null; + /** + * @var User + */ + private static $revUser = null; + /** * @var string */ @@ -54,6 +59,7 @@ class CategoryMembershipChangeTest extends MediaWikiLangTestCase { $page = WikiPage::factory( $title ); self::$pageRev = $page->getRevision(); + self::$revUser = User::newFromId( self::$pageRev->getUser( Revision::RAW ) ); } private function newChange( Revision $revision = null ) { @@ -114,7 +120,7 @@ class CategoryMembershipChangeTest extends MediaWikiLangTestCase { $this->assertTrue( strlen( self::$lastNotifyArgs[0] ) === 14 ); $this->assertEquals( 'Category:CategoryName', self::$lastNotifyArgs[1]->getPrefixedText() ); - $this->assertEquals( 'UTSysop', self::$lastNotifyArgs[2]->getName() ); + $this->assertEquals( self::$revUser->getName(), self::$lastNotifyArgs[2]->getName() ); $this->assertEquals( '(recentchanges-page-added-to-category: ' . self::$pageName . ')', self::$lastNotifyArgs[3] ); $this->assertEquals( self::$pageName, self::$lastNotifyArgs[4]->getPrefixedText() ); @@ -135,7 +141,7 @@ class CategoryMembershipChangeTest extends MediaWikiLangTestCase { $this->assertTrue( strlen( self::$lastNotifyArgs[0] ) === 14 ); $this->assertEquals( 'Category:CategoryName', self::$lastNotifyArgs[1]->getPrefixedText() ); - $this->assertEquals( 'UTSysop', self::$lastNotifyArgs[2]->getName() ); + $this->assertEquals( self::$revUser->getName(), self::$lastNotifyArgs[2]->getName() ); $this->assertEquals( '(recentchanges-page-removed-from-category: ' . self::$pageName . ')', self::$lastNotifyArgs[3] ); $this->assertEquals( self::$pageName, self::$lastNotifyArgs[4]->getPrefixedText() ); diff --git a/tests/phpunit/includes/changes/EnhancedChangesListTest.php b/tests/phpunit/includes/changes/EnhancedChangesListTest.php index b8be8d4ef8..308e6de11e 100644 --- a/tests/phpunit/includes/changes/EnhancedChangesListTest.php +++ b/tests/phpunit/includes/changes/EnhancedChangesListTest.php @@ -121,7 +121,7 @@ class EnhancedChangesListTest extends MediaWikiLangTestCase { * @return RecentChange */ private function getEditChange( $timestamp ) { - $user = $this->getTestUser(); + $user = $this->getMutableTestUser()->getUser(); $recentChange = $this->testRecentChangesHelper->makeEditRecentChange( $user, 'Cat', $timestamp, 5, 191, 190, 0, 0 ); @@ -139,7 +139,7 @@ class EnhancedChangesListTest extends MediaWikiLangTestCase { $wikiPage = new WikiPage( Title::newFromText( 'Category:Foo' ) ); $wikiPage->doEditContent( new WikitextContent( 'Some random text' ), 'category page created' ); - $user = $this->getTestUser(); + $user = $this->getMutableTestUser()->getUser(); $recentChange = $this->testRecentChangesHelper->makeCategorizationRecentChange( $user, 'Category:Foo', $wikiPage->getId(), $thisId, $lastId, $timestamp ); @@ -147,19 +147,6 @@ class EnhancedChangesListTest extends MediaWikiLangTestCase { return $recentChange; } - /** - * @return User - */ - private function getTestUser() { - $user = User::newFromName( 'TestRecentChangesUser' ); - - if ( !$user->getId() ) { - $user->addToDatabase(); - } - - return $user; - } - private function createCategorizationLine( $recentChange ) { $enhancedChangesList = $this->newEnhancedChangesList(); $cacheEntry = $this->testRecentChangesHelper->getCacheEntry( $recentChange ); diff --git a/tests/phpunit/includes/changes/OldChangesListTest.php b/tests/phpunit/includes/changes/OldChangesListTest.php index 5746a61dc4..51cfadcb6f 100644 --- a/tests/phpunit/includes/changes/OldChangesListTest.php +++ b/tests/phpunit/includes/changes/OldChangesListTest.php @@ -93,7 +93,6 @@ class OldChangesListTest extends MediaWikiLangTestCase { 'assert diff link' ); - $this->assertRegExp( '/tabindex="0"/', $line, 'assert tab index' ); $this->assertRegExp( '/title=Cat&curid=20131103212153&action=history"/', $line, @@ -151,7 +150,7 @@ class OldChangesListTest extends MediaWikiLangTestCase { } private function getNewBotEditChange() { - $user = $this->getTestUser(); + $user = $this->getMutableTestUser()->getUser(); $recentChange = $this->testRecentChangesHelper->makeNewBotEditRecentChange( $user, 'Abc', '20131103212153', 5, 191, 190, 0, 0 @@ -161,7 +160,7 @@ class OldChangesListTest extends MediaWikiLangTestCase { } private function getLogChange( $logType, $logAction ) { - $user = $this->getTestUser(); + $user = $this->getMutableTestUser()->getUser(); $recentChange = $this->testRecentChangesHelper->makeLogRecentChange( $logType, $logAction, $user, 'Abc', '20131103212153', 0, 0 @@ -171,7 +170,7 @@ class OldChangesListTest extends MediaWikiLangTestCase { } private function getEditChange() { - $user = $this->getTestUser(); + $user = $this->getMutableTestUser()->getUser(); $recentChange = $this->testRecentChangesHelper->makeEditRecentChange( $user, 'Cat', '20131103212153', 5, 191, 190, 0, 0 ); @@ -184,18 +183,8 @@ class OldChangesListTest extends MediaWikiLangTestCase { return new OldChangesList( $context ); } - private function getTestUser() { - $user = User::newFromName( 'TestRecentChangesUser' ); - - if ( !$user->getId() ) { - $user->addToDatabase(); - } - - return $user; - } - private function getContext() { - $user = $this->getTestUser(); + $user = $this->getMutableTestUser()->getUser(); $context = $this->testRecentChangesHelper->getTestContext( $user ); $context->setLanguage( 'qqx' ); diff --git a/tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php b/tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php index 602340b639..ccabab68a9 100644 --- a/tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php +++ b/tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php @@ -1,5 +1,8 @@ setMwGlobals( [ 'wgArticlePath' => '/wiki/$1' ] ); + + $this->linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); } - /** - * @dataProvider editChangeProvider - */ - public function testNewFromRecentChange( $expected, $context, $messages, - $recentChange, $watched - ) { - $cacheEntryFactory = new RCCacheEntryFactory( $context, $messages ); - $cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, $watched ); + public function testNewFromRecentChange() { + $user = $this->getMutableTestUser()->getUser(); + $recentChange = $this->testRecentChangesHelper->makeEditRecentChange( + $user, + 'Xyz', + 5, // curid + 191, // thisid + 190, // lastid + '20131103212153', + 0, // counter + 0 // number of watching users + ); + $cacheEntryFactory = new RCCacheEntryFactory( + $this->getContext(), + $this->getMessages(), + $this->linkRenderer + ); + $cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, false ); $this->assertInstanceOf( 'RCCacheEntry', $cacheEntry ); - $this->assertEquals( $watched, $cacheEntry->watched, 'watched' ); - $this->assertEquals( $expected['timestamp'], $cacheEntry->timestamp, 'timestamp' ); - $this->assertEquals( - $expected['numberofWatchingusers'], $cacheEntry->numberofWatchingusers, - 'watching users' - ); - $this->assertEquals( $expected['unpatrolled'], $cacheEntry->unpatrolled, 'unpatrolled' ); + $this->assertEquals( false, $cacheEntry->watched, 'watched' ); + $this->assertEquals( '21:21', $cacheEntry->timestamp, 'timestamp' ); + $this->assertEquals( 0, $cacheEntry->numberofWatchingusers, 'watching users' ); + $this->assertEquals( false, $cacheEntry->unpatrolled, 'unpatrolled' ); - $this->assertUserLinks( 'TestRecentChangesUser', $cacheEntry ); + $this->assertUserLinks( $user->getName(), $cacheEntry ); $this->assertTitleLink( 'Xyz', $cacheEntry ); - $this->assertQueryLink( 'cur', $expected['cur'], $cacheEntry->curlink, 'cur link' ); - $this->assertQueryLink( 'prev', $expected['diff'], $cacheEntry->lastlink, 'prev link' ); - $this->assertQueryLink( 'diff', $expected['diff'], $cacheEntry->difflink, 'diff link' ); + $diff = [ 'curid' => 5, 'diff' => 191, 'oldid' => 190 ]; + $cur = [ 'curid' => 5, 'diff' => 0, 'oldid' => 191 ]; + $this->assertQueryLink( 'cur', $cur, $cacheEntry->curlink, 'cur link' ); + $this->assertQueryLink( 'prev', $diff, $cacheEntry->lastlink, 'prev link' ); + $this->assertQueryLink( 'diff', $diff, $cacheEntry->difflink, 'diff link' ); } - public function editChangeProvider() { - return [ - [ - [ - 'title' => 'Xyz', - 'user' => 'TestRecentChangesUser', - 'diff' => [ 'curid' => 5, 'diff' => 191, 'oldid' => 190 ], - 'cur' => [ 'curid' => 5, 'diff' => 0, 'oldid' => 191 ], - 'timestamp' => '21:21', - 'numberofWatchingusers' => 0, - 'unpatrolled' => false - ], - $this->getContext(), - $this->getMessages(), - $this->testRecentChangesHelper->makeEditRecentChange( - $this->getTestUser(), - 'Xyz', - 5, // curid - 191, // thisid - 190, // lastid - '20131103212153', - 0, // counter - 0 // number of watching users - ), - false - ] + public function testNewForDeleteChange() { + $expected = [ + 'title' => 'Abc', + 'user' => 'TestRecentChangesUser', + 'timestamp' => '21:21', + 'numberofWatchingusers' => 0, + 'unpatrolled' => false ]; - } - - /** - * @dataProvider deleteChangeProvider - */ - public function testNewForDeleteChange( $expected, $context, $messages, $recentChange, $watched ) { - $cacheEntryFactory = new RCCacheEntryFactory( $context, $messages ); - $cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, $watched ); + $user = $this->getMutableTestUser()->getUser(); + $recentChange = $this->testRecentChangesHelper->makeLogRecentChange( + 'delete', + 'delete', + $user, + 'Abc', + '20131103212153', + 0, // counter + 0 // number of watching users + ); + $cacheEntryFactory = new RCCacheEntryFactory( + $this->getContext(), + $this->getMessages(), + $this->linkRenderer + ); + $cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, false ); $this->assertInstanceOf( 'RCCacheEntry', $cacheEntry ); - $this->assertEquals( $watched, $cacheEntry->watched, 'watched' ); - $this->assertEquals( $expected['timestamp'], $cacheEntry->timestamp, 'timestamp' ); - $this->assertEquals( - $expected['numberofWatchingusers'], - $cacheEntry->numberofWatchingusers, 'watching users' - ); - $this->assertEquals( $expected['unpatrolled'], $cacheEntry->unpatrolled, 'unpatrolled' ); + $this->assertEquals( false, $cacheEntry->watched, 'watched' ); + $this->assertEquals( '21:21', $cacheEntry->timestamp, 'timestamp' ); + $this->assertEquals( 0, $cacheEntry->numberofWatchingusers, 'watching users' ); + $this->assertEquals( false, $cacheEntry->unpatrolled, 'unpatrolled' ); $this->assertDeleteLogLink( $cacheEntry ); - $this->assertUserLinks( 'TestRecentChangesUser', $cacheEntry ); + $this->assertUserLinks( $user->getName(), $cacheEntry ); $this->assertEquals( 'cur', $cacheEntry->curlink, 'cur link for delete log or rev' ); $this->assertEquals( 'diff', $cacheEntry->difflink, 'diff link for delete log or rev' ); $this->assertEquals( 'prev', $cacheEntry->lastlink, 'pref link for delete log or rev' ); } - public function deleteChangeProvider() { - return [ - [ - [ - 'title' => 'Abc', - 'user' => 'TestRecentChangesUser', - 'timestamp' => '21:21', - 'numberofWatchingusers' => 0, - 'unpatrolled' => false - ], - $this->getContext(), - $this->getMessages(), - $this->testRecentChangesHelper->makeLogRecentChange( - 'delete', - 'delete', - $this->getTestUser(), - 'Abc', - '20131103212153', - 0, // counter - 0 // number of watching users - ), - false - ] - ]; - } - - /** - * @dataProvider revUserDeleteProvider - */ - public function testNewForRevUserDeleteChange( $expected, $context, $messages, - $recentChange, $watched - ) { - $cacheEntryFactory = new RCCacheEntryFactory( $context, $messages ); - $cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, $watched ); + public function testNewForRevUserDeleteChange() { + $user = $this->getMutableTestUser()->getUser(); + $recentChange = $this->testRecentChangesHelper->makeDeletedEditRecentChange( + $user, + 'Zzz', + '20131103212153', + 191, // thisid + 190, // lastid + '20131103212153', + 0, // counter + 0 // number of watching users + ); + $cacheEntryFactory = new RCCacheEntryFactory( + $this->getContext(), + $this->getMessages(), + $this->linkRenderer + ); + $cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, false ); $this->assertInstanceOf( 'RCCacheEntry', $cacheEntry ); - $this->assertEquals( $watched, $cacheEntry->watched, 'watched' ); - $this->assertEquals( $expected['timestamp'], $cacheEntry->timestamp, 'timestamp' ); - $this->assertEquals( - $expected['numberofWatchingusers'], - $cacheEntry->numberofWatchingusers, 'watching users' - ); - $this->assertEquals( $expected['unpatrolled'], $cacheEntry->unpatrolled, 'unpatrolled' ); + $this->assertEquals( false, $cacheEntry->watched, 'watched' ); + $this->assertEquals( '21:21', $cacheEntry->timestamp, 'timestamp' ); + $this->assertEquals( 0, $cacheEntry->numberofWatchingusers, 'watching users' ); + $this->assertEquals( false, $cacheEntry->unpatrolled, 'unpatrolled' ); $this->assertRevDel( $cacheEntry ); $this->assertTitleLink( 'Zzz', $cacheEntry ); @@ -162,35 +148,6 @@ class RCCacheEntryFactoryTest extends MediaWikiLangTestCase { $this->assertEquals( 'prev', $cacheEntry->lastlink, 'pref link for delete log or rev' ); } - public function revUserDeleteProvider() { - return [ - [ - [ - 'title' => 'Zzz', - 'user' => 'TestRecentChangesUser', - 'diff' => '', - 'cur' => '', - 'timestamp' => '21:21', - 'numberofWatchingusers' => 0, - 'unpatrolled' => false - ], - $this->getContext(), - $this->getMessages(), - $this->testRecentChangesHelper->makeDeletedEditRecentChange( - $this->getTestUser(), - 'Zzz', - '20131103212153', - 191, // thisid - 190, // lastid - '20131103212153', - 0, // counter - 0 // number of watching users - ), - false - ] - ]; - } - private function assertUserLinks( $user, $cacheEntry ) { $this->assertTag( [ @@ -308,18 +265,8 @@ class RCCacheEntryFactoryTest extends MediaWikiLangTestCase { ]; } - private function getTestUser() { - $user = User::newFromName( 'TestRecentChangesUser' ); - - if ( !$user->getId() ) { - $user->addToDatabase(); - } - - return $user; - } - private function getContext() { - $user = $this->getTestUser(); + $user = $this->getMutableTestUser()->getUser(); $context = $this->testRecentChangesHelper->getTestContext( $user ); $title = Title::newFromText( 'RecentChanges', NS_SPECIAL ); diff --git a/tests/phpunit/includes/changes/RecentChangeTest.php b/tests/phpunit/includes/changes/RecentChangeTest.php index 45f1382c47..995c4be16b 100644 --- a/tests/phpunit/includes/changes/RecentChangeTest.php +++ b/tests/phpunit/includes/changes/RecentChangeTest.php @@ -1,4 +1,5 @@ 'diff', 'cur' => 'cur', 'last' => 'last' ] + [ 'diff' => 'diff', 'cur' => 'cur', 'last' => 'last' ], + MediaWikiServices::getInstance()->getLinkRenderer() ); return $rcCacheFactory->newFromRecentChange( $recentChange, false ); } diff --git a/tests/phpunit/includes/config/ConfigFactoryTest.php b/tests/phpunit/includes/config/ConfigFactoryTest.php index 2c1d1e6f0e..8a766187c8 100644 --- a/tests/phpunit/includes/config/ConfigFactoryTest.php +++ b/tests/phpunit/includes/config/ConfigFactoryTest.php @@ -1,5 +1,7 @@ assertNotSame( $config1, $config2 ); } + /** + * @covers ConfigFactory::register + */ + public function testSalvage() { + $oldFactory = new ConfigFactory(); + $oldFactory->register( 'foo', 'GlobalVarConfig::newInstance' ); + $oldFactory->register( 'bar', 'GlobalVarConfig::newInstance' ); + $oldFactory->register( 'quux', 'GlobalVarConfig::newInstance' ); + + // instantiate two of the three defined configurations + $foo = $oldFactory->makeConfig( 'foo' ); + $bar = $oldFactory->makeConfig( 'bar' ); + $quux = $oldFactory->makeConfig( 'quux' ); + + // define new config instance + $newFactory = new ConfigFactory(); + $newFactory->register( 'foo', 'GlobalVarConfig::newInstance' ); + $newFactory->register( 'bar', function() { + return new HashConfig(); + } ); + + // "foo" and "quux" are defined in the old and the new factory. + // The old factory has instances for "foo" and "bar", but not "quux". + $newFactory->salvage( $oldFactory ); + + $newFoo = $newFactory->makeConfig( 'foo' ); + $this->assertSame( $foo, $newFoo, 'existing instance should be salvaged' ); + + $newBar = $newFactory->makeConfig( 'bar' ); + $this->assertNotSame( $bar, $newBar, 'don\'t salvage if callbacks differ' ); + + // the new factory doesn't have quux defined, so the quux instance should not be salvaged + $this->setExpectedException( 'ConfigException' ); + $newFactory->makeConfig( 'quux' ); + } + /** * @covers ConfigFactory::register */ @@ -104,7 +142,7 @@ class ConfigFactoryTest extends MediaWikiTestCase { public function testGetDefaultInstance() { // NOTE: the global config factory returned here has been overwritten // for operation in test mode. It may not reflect LocalSettings. - $factory = ConfigFactory::getDefaultInstance(); + $factory = MediaWikiServices::getInstance()->getConfigFactory(); $this->assertInstanceOf( 'Config', $factory->makeConfig( 'main' ) ); } diff --git a/tests/phpunit/includes/content/ContentHandlerTest.php b/tests/phpunit/includes/content/ContentHandlerTest.php index 91f27fbe71..39948ca130 100644 --- a/tests/phpunit/includes/content/ContentHandlerTest.php +++ b/tests/phpunit/includes/content/ContentHandlerTest.php @@ -1,7 +1,9 @@ resetNamespaces(); // And LinkCache - LinkCache::destroySingleton(); + MediaWikiServices::getInstance()->resetServiceForTesting( 'LinkCache' ); } protected function tearDown() { @@ -46,11 +48,16 @@ class ContentHandlerTest extends MediaWikiTestCase { MWNamespace::getCanonicalNamespaces( true ); $wgContLang->resetNamespaces(); // And LinkCache - LinkCache::destroySingleton(); + MediaWikiServices::getInstance()->resetServiceForTesting( 'LinkCache' ); parent::tearDown(); } + public function addDBDataOnce() { + $this->insertPage( 'Not_Main_Page', 'This is not a main page' ); + $this->insertPage( 'Smithee', 'A smithee is one who smiths. See also [[Alan Smithee]]' ); + } + public static function dataGetDefaultModelFor() { return [ [ 'Help:Foo', CONTENT_MODEL_WIKITEXT ], @@ -369,8 +376,7 @@ class ContentHandlerTest extends MediaWikiTestCase { $content = new WikitextContent( 'test text' ); $ok = ContentHandler::runLegacyHooks( 'testRunLegacyHooks', - [ 'foo', &$content, 'bar' ], - false + [ 'foo', &$content, 'bar' ] ); $this->assertTrue( $ok, "runLegacyHooks should have returned true" ); @@ -408,4 +414,65 @@ class ContentHandlerTest extends MediaWikiTestCase { $this->assertInstanceOf( $handlerClass, $handler ); } + public function testGetFieldsForSearchIndex() { + $searchEngine = $this->newSearchEngine(); + + $handler = ContentHandler::getForModelID( CONTENT_MODEL_WIKITEXT ); + + $fields = $handler->getFieldsForSearchIndex( $searchEngine ); + + $this->assertArrayHasKey( 'category', $fields ); + $this->assertArrayHasKey( 'external_link', $fields ); + $this->assertArrayHasKey( 'outgoing_link', $fields ); + $this->assertArrayHasKey( 'template', $fields ); + } + + private function newSearchEngine() { + $searchEngine = $this->getMockBuilder( 'SearchEngine' ) + ->getMock(); + + $searchEngine->expects( $this->any() ) + ->method( 'makeSearchFieldMapping' ) + ->will( $this->returnCallback( function( $name, $type ) { + return new DummySearchIndexFieldDefinition( $name, $type ); + } ) ); + + return $searchEngine; + } + + /** + * @covers ContentHandler::getDataForSearchIndex + */ + public function testDataIndexFields() { + $mockEngine = $this->getMock( 'SearchEngine' ); + $title = Title::newFromText( 'Not_Main_Page', NS_MAIN ); + $page = new WikiPage( $title ); + + $this->setTemporaryHook( 'SearchDataForIndex', + function ( &$fields, ContentHandler $handler, WikiPage $page, ParserOutput $output, + SearchEngine $engine ) { + $fields['testDataField'] = 'test content'; + } ); + + $output = $page->getContent()->getParserOutput( $title ); + $data = $page->getContentHandler()->getDataForSearchIndex( $page, $output, $mockEngine ); + $this->assertArrayHasKey( 'text', $data ); + $this->assertArrayHasKey( 'text_bytes', $data ); + $this->assertArrayHasKey( 'language', $data ); + $this->assertArrayHasKey( 'testDataField', $data ); + $this->assertEquals( 'test content', $data['testDataField'] ); + } + + /** + * @covers ContentHandler::getParserOutputForIndexing + */ + public function testParserOutputForIndexing() { + $title = Title::newFromText( 'Smithee', NS_MAIN ); + $page = new WikiPage( $title ); + + $out = $page->getContentHandler()->getParserOutputForIndexing( $page ); + $this->assertInstanceOf( ParserOutput::class, $out ); + $this->assertContains( 'one who smiths', $out->getRawText() ); + } + } diff --git a/tests/phpunit/includes/content/CssContentTest.php b/tests/phpunit/includes/content/CssContentTest.php index a2aef35f5e..d2078d7a23 100644 --- a/tests/phpunit/includes/content/CssContentTest.php +++ b/tests/phpunit/includes/content/CssContentTest.php @@ -101,15 +101,15 @@ class CssContentTest extends JavaScriptContentTest { */ public static function provideGetRedirectTarget() { // @codingStandardsIgnoreStart Generic.Files.LineLength - return array( - array( 'MediaWiki:MonoBook.css', "/* #REDIRECT */@import url(//example.org/w/index.php?title=MediaWiki:MonoBook.css&action=raw&ctype=text/css);" ), - array( 'User:FooBar/common.css', "/* #REDIRECT */@import url(//example.org/w/index.php?title=User:FooBar/common.css&action=raw&ctype=text/css);" ), - array( 'Gadget:FooBaz.css', "/* #REDIRECT */@import url(//example.org/w/index.php?title=Gadget:FooBaz.css&action=raw&ctype=text/css);" ), + return [ + [ 'MediaWiki:MonoBook.css', "/* #REDIRECT */@import url(//example.org/w/index.php?title=MediaWiki:MonoBook.css&action=raw&ctype=text/css);" ], + [ 'User:FooBar/common.css', "/* #REDIRECT */@import url(//example.org/w/index.php?title=User:FooBar/common.css&action=raw&ctype=text/css);" ], + [ 'Gadget:FooBaz.css', "/* #REDIRECT */@import url(//example.org/w/index.php?title=Gadget:FooBaz.css&action=raw&ctype=text/css);" ], # No #REDIRECT comment - array( null, "@import url(//example.org/w/index.php?title=Gadget:FooBaz.css&action=raw&ctype=text/css);" ), + [ null, "@import url(//example.org/w/index.php?title=Gadget:FooBaz.css&action=raw&ctype=text/css);" ], # Wrong domain - array( null, "/* #REDIRECT */@import url(//example.com/w/index.php?title=Gadget:FooBaz.css&action=raw&ctype=text/css);" ), - ); + [ null, "/* #REDIRECT */@import url(//example.com/w/index.php?title=Gadget:FooBaz.css&action=raw&ctype=text/css);" ], + ]; // @codingStandardsIgnoreEnd } diff --git a/tests/phpunit/includes/content/FileContentHandlerTest.php b/tests/phpunit/includes/content/FileContentHandlerTest.php new file mode 100644 index 0000000000..276a86ee5e --- /dev/null +++ b/tests/phpunit/includes/content/FileContentHandlerTest.php @@ -0,0 +1,50 @@ +handler = new FileContentHandler(); + } + + public function testIndexMapping() { + $mockEngine = $this->getMock( 'SearchEngine' ); + + $mockEngine->expects( $this->atLeastOnce() ) + ->method( 'makeSearchFieldMapping' ) + ->willReturnCallback( function ( $name, $type ) { + $mockField = + $this->getMockBuilder( 'SearchIndexFieldDefinition' ) + ->setMethods( [ 'getMapping' ] ) + ->setConstructorArgs( [ $name, $type ] ) + ->getMock(); + return $mockField; + } ); + + $map = $this->handler->getFieldsForSearchIndex( $mockEngine ); + $expect = [ + 'file_media_type' => 1, + 'file_mime' => 1, + 'file_size' => 1, + 'file_width' => 1, + 'file_height' => 1, + 'file_bits' => 1, + 'file_resolution' => 1, + 'file_text' => 1, + ]; + foreach ( $map as $name => $field ) { + $this->assertInstanceOf( 'SearchIndexField', $field ); + $this->assertEquals( $name, $field->getName() ); + unset( $expect[$name] ); + } + $this->assertEmpty( $expect ); + } +} diff --git a/tests/phpunit/includes/content/JsonContentHandlerTest.php b/tests/phpunit/includes/content/JsonContentHandlerTest.php new file mode 100644 index 0000000000..abfb6733a5 --- /dev/null +++ b/tests/phpunit/includes/content/JsonContentHandlerTest.php @@ -0,0 +1,14 @@ +makeEmptyContent(); + $this->assertInstanceOf( JsonContent::class, $content ); + $this->assertTrue( $content->isValid() ); + } +} diff --git a/tests/phpunit/includes/content/JsonContentTest.php b/tests/phpunit/includes/content/JsonContentTest.php index 8a48734ed1..de8e371ebf 100644 --- a/tests/phpunit/includes/content/JsonContentTest.php +++ b/tests/phpunit/includes/content/JsonContentTest.php @@ -6,12 +6,6 @@ */ class JsonContentTest extends MediaWikiLangTestCase { - protected function setUp() { - parent::setUp(); - - $this->setMwGlobals( 'wgWellFormedXml', true ); - } - public static function provideValidConstruction() { return [ [ 'foo', false, null ], diff --git a/tests/phpunit/includes/content/TextContentHandlerTest.php b/tests/phpunit/includes/content/TextContentHandlerTest.php index 492fec6b68..918815ca05 100644 --- a/tests/phpunit/includes/content/TextContentHandlerTest.php +++ b/tests/phpunit/includes/content/TextContentHandlerTest.php @@ -9,4 +9,44 @@ class TextContentHandlerTest extends MediaWikiLangTestCase { $this->assertTrue( $handler->supportsDirectEditing(), 'direct editing is supported' ); } + /** + * @covers SearchEngine::makeSearchFieldMapping + * @covers ContentHandler::getFieldsForSearchIndex + */ + public function testFieldsForIndex() { + $handler = new TextContentHandler(); + + $mockEngine = $this->getMock( 'SearchEngine' ); + + $mockEngine->expects( $this->atLeastOnce() ) + ->method( 'makeSearchFieldMapping' ) + ->willReturnCallback( function ( $name, $type ) { + $mockField = + $this->getMockBuilder( 'SearchIndexFieldDefinition' ) + ->setConstructorArgs( [ $name, $type ] ) + ->getMock(); + $mockField->expects( $this->atLeastOnce() )->method( 'getMapping' )->willReturn( [ + 'testData' => 'test', + 'name' => $name, + 'type' => $type, + ] ); + return $mockField; + } ); + + /** + * @var $mockEngine SearchEngine + */ + $fields = $handler->getFieldsForSearchIndex( $mockEngine ); + $mappedFields = []; + foreach ( $fields as $name => $field ) { + $this->assertInstanceOf( 'SearchIndexField', $field ); + /** + * @var $field SearchIndexField + */ + $mappedFields[$name] = $field->getMapping( $mockEngine ); + } + $this->assertArrayHasKey( 'language', $mappedFields ); + $this->assertEquals( 'test', $mappedFields['language']['testData'] ); + $this->assertEquals( 'language', $mappedFields['language']['name'] ); + } } diff --git a/tests/phpunit/includes/content/TextContentTest.php b/tests/phpunit/includes/content/TextContentTest.php index ac8342826c..b290f8f281 100644 --- a/tests/phpunit/includes/content/TextContentTest.php +++ b/tests/phpunit/includes/content/TextContentTest.php @@ -102,6 +102,11 @@ class TextContentTest extends MediaWikiLangTestCase { " Foo \n ", ' Foo', ], + [ + # 2: newline normalization + "LF\n\nCRLF\r\n\r\nCR\r\rEND", + "LF\n\nCRLF\n\nCR\n\nEND", + ], ]; } @@ -454,4 +459,30 @@ class TextContentTest extends MediaWikiLangTestCase { $this->assertEquals( $expectedNative, $converted->getNativeData() ); } } + + /** + * @covers TextContent::normalizeLineEndings + * @dataProvider provideNormalizeLineEndings + */ + public function testNormalizeLineEndings( $input, $expected ) { + $this->assertEquals( $expected, TextContent::normalizeLineEndings( $input ) ); + } + + public static function provideNormalizeLineEndings() { + return [ + [ + "Foo\r\nbar", + "Foo\nbar" + ], + [ + "Foo\rbar", + "Foo\nbar" + ], + [ + "Foobar\n ", + "Foobar" + ] + ]; + } + } diff --git a/tests/phpunit/includes/content/WikitextContentHandlerTest.php b/tests/phpunit/includes/content/WikitextContentHandlerTest.php index f6328821e4..ec97d76371 100644 --- a/tests/phpunit/includes/content/WikitextContentHandlerTest.php +++ b/tests/phpunit/includes/content/WikitextContentHandlerTest.php @@ -243,4 +243,29 @@ class WikitextContentHandlerTest extends MediaWikiLangTestCase { ) { } */ + + public function testDataIndexFieldsFile() { + $mockEngine = $this->getMock( 'SearchEngine' ); + $title = Title::newFromText( 'Somefile.jpg', NS_FILE ); + $page = new WikiPage( $title ); + + $fileHandler = $this->getMockBuilder( FileContentHandler::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'getDataForSearchIndex' ] ) + ->getMock(); + + $handler = $this->getMockBuilder( WikitextContentHandler::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'getFileHandler' ] ) + ->getMock(); + + $handler->method( 'getFileHandler' )->will( $this->returnValue( $fileHandler ) ); + $fileHandler->expects( $this->once() ) + ->method( 'getDataForSearchIndex' ) + ->will( $this->returnValue( [ 'file_text' => 'This is file content' ] ) ); + + $data = $handler->getDataForSearchIndex( $page, new ParserOutput(), $mockEngine ); + $this->assertArrayHasKey( 'file_text', $data ); + $this->assertEquals( 'This is file content', $data['file_text'] ); + } } diff --git a/tests/phpunit/includes/content/WikitextStructureTest.php b/tests/phpunit/includes/content/WikitextStructureTest.php new file mode 100644 index 0000000000..49907c8532 --- /dev/null +++ b/tests/phpunit/includes/content/WikitextStructureTest.php @@ -0,0 +1,107 @@ +getParserOutput( $this->getMockTitle() ); + } + + /** + * Get WikitextStructure for given text + * @param $text + * @return WikiTextStructure + */ + private function getStructure( $text ) { + return new WikiTextStructure( $this->getParserOutput( $text ) ); + } + + public function testHeadings() { + $text = <<2 === +and more text +== Wikitext '''in''' [[Heading]] and also html == +more text +==== See also ==== +* Also things to see! +END; + $struct = $this->getStructure( $text ); + $headings = $struct->headings(); + $this->assertCount( 4, $headings ); + $this->assertContains( "Heading one", $headings ); + $this->assertContains( "heading two", $headings ); + $this->assertContains( "Applicability of the strict mass-energy equivalence formula, E = mc2", + $headings ); + $this->assertContains( "Wikitext in Heading and also html", $headings ); + } + + public function testDefaultSort() { + $text = <<getStructure( $text ); + $this->assertEquals( "Michel, Louise", $struct->getDefaultSort() ); + } + + public function testHeadingsFirst() { + $text = <<getStructure( $text ); + $headings = $struct->headings(); + $this->assertCount( 2, $headings ); + $this->assertContains( "Heading one", $headings ); + $this->assertContains( "heading two", $headings ); + } + + public function testHeadingsNone() { + $text = "This text is completely devoid of headings."; + $struct = $this->getStructure( $text ); + $headings = $struct->headings(); + $this->assertArrayEquals( [], $headings ); + } + + public function testTexts() { + $text = <<text +=== And more headers === +{| class="wikitable" +|- +! Header table +|- +| row in table +|- +| another row in table +|} +END; + $struct = $this->getStructure( $text ); + $this->assertEquals( "Opening text is opening.", $struct->getOpeningText() ); + $this->assertEquals( "Opening text is opening. Then we got more text", + $struct->getMainText() ); + $this->assertEquals( [ "Header table row in table another row in table" ], + $struct->getAuxiliaryText() ); + } +} diff --git a/tests/phpunit/includes/db/DatabaseMysqlBaseTest.php b/tests/phpunit/includes/db/DatabaseMysqlBaseTest.php index bb747c7c95..dbb126f300 100644 --- a/tests/phpunit/includes/db/DatabaseMysqlBaseTest.php +++ b/tests/phpunit/includes/db/DatabaseMysqlBaseTest.php @@ -29,8 +29,17 @@ * Fake class around abstract class so we can call concrete methods. */ class FakeDatabaseMysqlBase extends DatabaseMysqlBase { - // From DatabaseBase + // From Database function __construct() { + $this->profiler = new ProfilerStub( [] ); + $this->trxProfiler = new TransactionProfiler(); + $this->cliMode = true; + $this->connLogger = new \Psr\Log\NullLogger(); + $this->queryLogger = new \Psr\Log\NullLogger(); + $this->errorLogger = function ( Exception $e ) { + wfWarn( get_class( $e ) . ": {$e->getMessage()}" ); + }; + $this->currentDomain = DatabaseDomain::newUnspecified(); } protected function closeConnection() { @@ -76,14 +85,10 @@ class FakeDatabaseMysqlBase extends DatabaseMysqlBase { protected function mysqlFetchField( $res, $n ) { } - protected function mysqlPing() { - } - protected function mysqlRealEscapeString( $s ) { } - // From interface DatabaseType function insertId() { } @@ -170,22 +175,14 @@ class DatabaseMysqlBaseTest extends MediaWikiTestCase { ->setMethods( [ 'fetchRow', 'query' ] ) ->getMock(); - $db->expects( $this->any() ) - ->method( 'query' ) + $db->method( 'query' ) ->with( $this->anything() ) - ->will( - $this->returnValue( null ) - ); + ->willReturn( new FakeResultWrapper( [ + (object)[ 'Tables_in_' => 'view1' ], + (object)[ 'Tables_in_' => 'view2' ], + (object)[ 'Tables_in_' => 'myview' ] + ] ) ); - $db->expects( $this->any() ) - ->method( 'fetchRow' ) - ->with( $this->anything() ) - ->will( $this->onConsecutiveCalls( - [ 'Tables_in_' => 'view1' ], - [ 'Tables_in_' => 'view2' ], - [ 'Tables_in_' => 'myview' ], - false # no more rows - ) ); return $db; } /** @@ -194,9 +191,6 @@ class DatabaseMysqlBaseTest extends MediaWikiTestCase { function testListviews() { $db = $this->getMockForViews(); - // The first call populate an internal cache of views - $this->assertEquals( [ 'view1', 'view2', 'myview' ], - $db->listViews() ); $this->assertEquals( [ 'view1', 'view2', 'myview' ], $db->listViews() ); @@ -211,65 +205,74 @@ class DatabaseMysqlBaseTest extends MediaWikiTestCase { $db->listViews( '' ) ); } - /** - * @covers DatabaseMysqlBase::isView - * @dataProvider provideViewExistanceChecks - */ - function testIsView( $isView, $viewName ) { - $db = $this->getMockForViews(); - - switch ( $isView ) { - case true: - $this->assertTrue( $db->isView( $viewName ), - "$viewName should be considered a view" ); - break; - - case false: - $this->assertFalse( $db->isView( $viewName ), - "$viewName has not been defined as a view" ); - break; - } - - } - - function provideViewExistanceChecks() { - return [ - // format: whether it is a view, view name - [ true, 'view1' ], - [ true, 'view2' ], - [ true, 'myview' ], - - [ false, 'user' ], - - [ false, 'view10' ], - [ false, 'my' ], - [ false, 'OH_MY_GOD' ], # they killed kenny! - ]; - } - /** * @dataProvider provideComparePositions */ - function testHasReached( MySQLMasterPos $lowerPos, MySQLMasterPos $higherPos ) { - $this->assertTrue( $higherPos->hasReached( $lowerPos ) ); - $this->assertTrue( $higherPos->hasReached( $higherPos ) ); - $this->assertTrue( $lowerPos->hasReached( $lowerPos ) ); - $this->assertFalse( $lowerPos->hasReached( $higherPos ) ); + function testHasReached( MySQLMasterPos $lowerPos, MySQLMasterPos $higherPos, $match ) { + if ( $match ) { + $this->assertTrue( $lowerPos->channelsMatch( $higherPos ) ); + + $this->assertTrue( $higherPos->hasReached( $lowerPos ) ); + $this->assertTrue( $higherPos->hasReached( $higherPos ) ); + $this->assertTrue( $lowerPos->hasReached( $lowerPos ) ); + $this->assertFalse( $lowerPos->hasReached( $higherPos ) ); + } else { // channels don't match + $this->assertFalse( $lowerPos->channelsMatch( $higherPos ) ); + + $this->assertFalse( $higherPos->hasReached( $lowerPos ) ); + $this->assertFalse( $lowerPos->hasReached( $higherPos ) ); + } } function provideComparePositions() { return [ + // Binlog style [ new MySQLMasterPos( 'db1034-bin.000976', '843431247' ), - new MySQLMasterPos( 'db1034-bin.000976', '843431248' ) + new MySQLMasterPos( 'db1034-bin.000976', '843431248' ), + true ], [ new MySQLMasterPos( 'db1034-bin.000976', '999' ), - new MySQLMasterPos( 'db1034-bin.000976', '1000' ) + new MySQLMasterPos( 'db1034-bin.000976', '1000' ), + true ], [ new MySQLMasterPos( 'db1034-bin.000976', '999' ), - new MySQLMasterPos( 'db1035-bin.000976', '1000' ) + new MySQLMasterPos( 'db1035-bin.000976', '1000' ), + false + ], + // MySQL GTID style + [ + new MySQLMasterPos( 'db1-bin.2', '1', '3E11FA47-71CA-11E1-9E33-C80AA9429562:23' ), + new MySQLMasterPos( 'db1-bin.2', '2', '3E11FA47-71CA-11E1-9E33-C80AA9429562:24' ), + true + ], + [ + new MySQLMasterPos( 'db1-bin.2', '1', '3E11FA47-71CA-11E1-9E33-C80AA9429562:99' ), + new MySQLMasterPos( 'db1-bin.2', '2', '3E11FA47-71CA-11E1-9E33-C80AA9429562:100' ), + true + ], + [ + new MySQLMasterPos( 'db1-bin.2', '1', '3E11FA47-71CA-11E1-9E33-C80AA9429562:99' ), + new MySQLMasterPos( 'db1-bin.2', '2', '1E11FA47-71CA-11E1-9E33-C80AA9429562:100' ), + false + ], + // MariaDB GTID style + [ + new MySQLMasterPos( 'db1-bin.2', '1', '255-11-23' ), + new MySQLMasterPos( 'db1-bin.2', '2', '255-11-24' ), + true + ], + [ + new MySQLMasterPos( 'db1-bin.2', '1', '255-11-99' ), + new MySQLMasterPos( 'db1-bin.2', '2', '255-11-100' ), + true + ], + [ + new MySQLMasterPos( 'db1-bin.2', '1', '255-11-999' ), + new MySQLMasterPos( 'db1-bin.2', '2', '254-11-1000' ), + false ], ]; } @@ -317,13 +320,11 @@ class DatabaseMysqlBaseTest extends MediaWikiTestCase { 'getLagDetectionMethod', 'getHeartbeatData', 'getMasterServerInfo' ] ) ->getMock(); - $db->expects( $this->any() ) - ->method( 'getLagDetectionMethod' ) - ->will( $this->returnValue( 'pt-heartbeat' ) ); + $db->method( 'getLagDetectionMethod' ) + ->willReturn( 'pt-heartbeat' ); - $db->expects( $this->any() ) - ->method( 'getMasterServerInfo' ) - ->will( $this->returnValue( [ 'serverId' => 172, 'asOf' => time() ] ) ); + $db->method( 'getMasterServerInfo' ) + ->willReturn( [ 'serverId' => 172, 'asOf' => time() ] ); // Fake the current time. list( $nowSecFrac, $nowSec ) = explode( ' ', microtime() ); @@ -337,10 +338,9 @@ class DatabaseMysqlBaseTest extends MediaWikiTestCase { $ptTimeISO = $ptDateTime->format( 'Y-m-d\TH:i:s' ); $ptTimeISO .= ltrim( number_format( $ptSecFrac, 6 ), '0' ); - $db->expects( $this->any() ) - ->method( 'getHeartbeatData' ) + $db->method( 'getHeartbeatData' ) ->with( [ 'server_id' => 172 ] ) - ->will( $this->returnValue( [ $ptTimeISO, $now ] ) ); + ->willReturn( [ $ptTimeISO, $now ] ); $db->setLBInfo( 'clusterMasterHost', 'db1052' ); $lagEst = $db->getLag(); diff --git a/tests/phpunit/includes/db/DatabaseSQLTest.php b/tests/phpunit/includes/db/DatabaseSQLTest.php index 5d5521ccfe..656e661690 100644 --- a/tests/phpunit/includes/db/DatabaseSQLTest.php +++ b/tests/phpunit/includes/db/DatabaseSQLTest.php @@ -5,15 +5,12 @@ * This is a non DBMS depending test. */ class DatabaseSQLTest extends MediaWikiTestCase { - - /** - * @var DatabaseTestHelper - */ + /** @var DatabaseTestHelper */ private $database; protected function setUp() { parent::setUp(); - $this->database = new DatabaseTestHelper( __CLASS__ ); + $this->database = new DatabaseTestHelper( __CLASS__, [ 'cliMode' => true ] ); } protected function assertLastSql( $sqlText ) { @@ -23,9 +20,13 @@ class DatabaseSQLTest extends MediaWikiTestCase { ); } + protected function assertLastSqlDb( $sqlText, $db ) { + $this->assertEquals( $db->getLastSqls(), $sqlText ); + } + /** * @dataProvider provideSelect - * @covers DatabaseBase::select + * @covers Database::select */ public function testSelect( $sql, $sqlText ) { $this->database->select( @@ -131,7 +132,7 @@ class DatabaseSQLTest extends MediaWikiTestCase { /** * @dataProvider provideUpdate - * @covers DatabaseBase::update + * @covers Database::update */ public function testUpdate( $sql, $sqlText ) { $this->database->update( @@ -183,7 +184,7 @@ class DatabaseSQLTest extends MediaWikiTestCase { /** * @dataProvider provideDelete - * @covers DatabaseBase::delete + * @covers Database::delete */ public function testDelete( $sql, $sqlText ) { $this->database->delete( @@ -216,7 +217,7 @@ class DatabaseSQLTest extends MediaWikiTestCase { /** * @dataProvider provideUpsert - * @covers DatabaseBase::upsert + * @covers Database::upsert */ public function testUpsert( $sql, $sqlText ) { $this->database->upsert( @@ -252,7 +253,7 @@ class DatabaseSQLTest extends MediaWikiTestCase { /** * @dataProvider provideDeleteJoin - * @covers DatabaseBase::deleteJoin + * @covers Database::deleteJoin */ public function testDeleteJoin( $sql, $sqlText ) { $this->database->deleteJoin( @@ -299,7 +300,7 @@ class DatabaseSQLTest extends MediaWikiTestCase { /** * @dataProvider provideInsert - * @covers DatabaseBase::insert + * @covers Database::insert */ public function testInsert( $sql, $sqlText ) { $this->database->insert( @@ -352,9 +353,9 @@ class DatabaseSQLTest extends MediaWikiTestCase { /** * @dataProvider provideInsertSelect - * @covers DatabaseBase::insertSelect + * @covers Database::insertSelect */ - public function testInsertSelect( $sql, $sqlText ) { + public function testInsertSelect( $sql, $sqlTextNative, $sqlSelect, $sqlInsert ) { $this->database->insertSelect( $sql['destTable'], $sql['srcTable'], @@ -364,7 +365,22 @@ class DatabaseSQLTest extends MediaWikiTestCase { isset( $sql['insertOptions'] ) ? $sql['insertOptions'] : [], isset( $sql['selectOptions'] ) ? $sql['selectOptions'] : [] ); - $this->assertLastSql( $sqlText ); + $this->assertLastSql( $sqlTextNative ); + + $dbWeb = new DatabaseTestHelper( __CLASS__, [ 'cliMode' => false ] ); + $dbWeb->forceNextResult( [ + array_flip( array_keys( $sql['varMap'] ) ) + ] ); + $dbWeb->insertSelect( + $sql['destTable'], + $sql['srcTable'], + $sql['varMap'], + $sql['conds'], + __METHOD__, + isset( $sql['insertOptions'] ) ? $sql['insertOptions'] : [], + isset( $sql['selectOptions'] ) ? $sql['selectOptions'] : [] + ); + $this->assertLastSqlDb( implode( '; ', [ $sqlSelect, $sqlInsert ] ), $dbWeb ); } public static function provideInsertSelect() { @@ -379,7 +395,10 @@ class DatabaseSQLTest extends MediaWikiTestCase { "INSERT INTO insert_table " . "(field_insert,field) " . "SELECT field_select,field2 " . - "FROM select_table" + "FROM select_table", + "SELECT field_select AS field_insert,field2 AS field " . + "FROM select_table WHERE * FOR UPDATE", + "INSERT INTO insert_table (field_insert,field) VALUES ('0','1')" ], [ [ @@ -392,7 +411,10 @@ class DatabaseSQLTest extends MediaWikiTestCase { "(field_insert,field) " . "SELECT field_select,field2 " . "FROM select_table " . - "WHERE field = '2'" + "WHERE field = '2'", + "SELECT field_select AS field_insert,field2 AS field FROM " . + "select_table WHERE field = '2' FOR UPDATE", + "INSERT INTO insert_table (field_insert,field) VALUES ('0','1')" ], [ [ @@ -408,14 +430,17 @@ class DatabaseSQLTest extends MediaWikiTestCase { "SELECT field_select,field2 " . "FROM select_table " . "WHERE field = '2' " . - "ORDER BY field" + "ORDER BY field", + "SELECT field_select AS field_insert,field2 AS field " . + "FROM select_table WHERE field = '2' ORDER BY field FOR UPDATE", + "INSERT IGNORE INTO insert_table (field_insert,field) VALUES ('0','1')" ], ]; } /** * @dataProvider provideReplace - * @covers DatabaseBase::replace + * @covers Database::replace */ public function testReplace( $sql, $sqlText ) { $this->database->replace( @@ -530,7 +555,7 @@ class DatabaseSQLTest extends MediaWikiTestCase { /** * @dataProvider provideNativeReplace - * @covers DatabaseBase::nativeReplace + * @covers Database::nativeReplace */ public function testNativeReplace( $sql, $sqlText ) { $this->database->nativeReplace( @@ -557,7 +582,7 @@ class DatabaseSQLTest extends MediaWikiTestCase { /** * @dataProvider provideConditional - * @covers DatabaseBase::conditional + * @covers Database::conditional */ public function testConditional( $sql, $sqlText ) { $this->assertEquals( trim( $this->database->conditional( @@ -598,7 +623,7 @@ class DatabaseSQLTest extends MediaWikiTestCase { /** * @dataProvider provideBuildConcat - * @covers DatabaseBase::buildConcat + * @covers Database::buildConcat */ public function testBuildConcat( $stringList, $sqlText ) { $this->assertEquals( trim( $this->database->buildConcat( @@ -621,7 +646,7 @@ class DatabaseSQLTest extends MediaWikiTestCase { /** * @dataProvider provideBuildLike - * @covers DatabaseBase::buildLike + * @covers Database::buildLike */ public function testBuildLike( $array, $sqlText ) { $this->assertEquals( trim( $this->database->buildLike( @@ -652,7 +677,7 @@ class DatabaseSQLTest extends MediaWikiTestCase { /** * @dataProvider provideUnionQueries - * @covers DatabaseBase::unionQueries + * @covers Database::unionQueries */ public function testUnionQueries( $sql, $sqlText ) { $this->assertEquals( trim( $this->database->unionQueries( @@ -688,7 +713,7 @@ class DatabaseSQLTest extends MediaWikiTestCase { } /** - * @covers DatabaseBase::commit + * @covers Database::commit */ public function testTransactionCommit() { $this->database->begin( __METHOD__ ); @@ -697,7 +722,7 @@ class DatabaseSQLTest extends MediaWikiTestCase { } /** - * @covers DatabaseBase::rollback + * @covers Database::rollback */ public function testTransactionRollback() { $this->database->begin( __METHOD__ ); @@ -706,16 +731,16 @@ class DatabaseSQLTest extends MediaWikiTestCase { } /** - * @covers DatabaseBase::dropTable + * @covers Database::dropTable */ public function testDropTable() { $this->database->setExistingTables( [ 'table' ] ); $this->database->dropTable( 'table', __METHOD__ ); - $this->assertLastSql( 'DROP TABLE table' ); + $this->assertLastSql( 'DROP TABLE table CASCADE' ); } /** - * @covers DatabaseBase::dropTable + * @covers Database::dropTable */ public function testDropNonExistingTable() { $this->assertFalse( @@ -725,7 +750,7 @@ class DatabaseSQLTest extends MediaWikiTestCase { /** * @dataProvider provideMakeList - * @covers DatabaseBase::makeList + * @covers Database::makeList */ public function testMakeList( $list, $mode, $sqlText ) { $this->assertEquals( trim( $this->database->makeList( @@ -802,4 +827,42 @@ class DatabaseSQLTest extends MediaWikiTestCase { ], ]; } + + public function testSessionTempTables() { + $temp1 = $this->database->tableName( 'tmp_table_1' ); + $temp2 = $this->database->tableName( 'tmp_table_2' ); + $temp3 = $this->database->tableName( 'tmp_table_3' ); + + $this->database->query( "CREATE TEMPORARY TABLE $temp1 LIKE orig_tbl", __METHOD__ ); + $this->database->query( "CREATE TEMPORARY TABLE $temp2 LIKE orig_tbl", __METHOD__ ); + $this->database->query( "CREATE TEMPORARY TABLE $temp3 LIKE orig_tbl", __METHOD__ ); + + $this->assertTrue( $this->database->tableExists( "tmp_table_1", __METHOD__ ) ); + $this->assertTrue( $this->database->tableExists( "tmp_table_2", __METHOD__ ) ); + $this->assertTrue( $this->database->tableExists( "tmp_table_3", __METHOD__ ) ); + + $this->database->dropTable( 'tmp_table_1', __METHOD__ ); + $this->database->dropTable( 'tmp_table_2', __METHOD__ ); + $this->database->dropTable( 'tmp_table_3', __METHOD__ ); + + $this->assertFalse( $this->database->tableExists( "tmp_table_1", __METHOD__ ) ); + $this->assertFalse( $this->database->tableExists( "tmp_table_2", __METHOD__ ) ); + $this->assertFalse( $this->database->tableExists( "tmp_table_3", __METHOD__ ) ); + + $this->database->query( "CREATE TEMPORARY TABLE tmp_table_1 LIKE orig_tbl", __METHOD__ ); + $this->database->query( "CREATE TEMPORARY TABLE 'tmp_table_2' LIKE orig_tbl", __METHOD__ ); + $this->database->query( "CREATE TEMPORARY TABLE `tmp_table_3` LIKE orig_tbl", __METHOD__ ); + + $this->assertTrue( $this->database->tableExists( "tmp_table_1", __METHOD__ ) ); + $this->assertTrue( $this->database->tableExists( "tmp_table_2", __METHOD__ ) ); + $this->assertTrue( $this->database->tableExists( "tmp_table_3", __METHOD__ ) ); + + $this->database->query( "DROP TEMPORARY TABLE tmp_table_1 LIKE orig_tbl", __METHOD__ ); + $this->database->query( "DROP TEMPORARY TABLE 'tmp_table_2' LIKE orig_tbl", __METHOD__ ); + $this->database->query( "DROP TABLE `tmp_table_3` LIKE orig_tbl", __METHOD__ ); + + $this->assertFalse( $this->database->tableExists( "tmp_table_1", __METHOD__ ) ); + $this->assertFalse( $this->database->tableExists( "tmp_table_2", __METHOD__ ) ); + $this->assertFalse( $this->database->tableExists( "tmp_table_3", __METHOD__ ) ); + } } diff --git a/tests/phpunit/includes/db/DatabaseSqliteTest.php b/tests/phpunit/includes/db/DatabaseSqliteTest.php index 80fb826bf3..172d68650d 100644 --- a/tests/phpunit/includes/db/DatabaseSqliteTest.php +++ b/tests/phpunit/includes/db/DatabaseSqliteTest.php @@ -7,7 +7,7 @@ class DatabaseSqliteMock extends DatabaseSqlite { $p['dbFilePath'] = ':memory:'; $p['schema'] = false; - return DatabaseBase::factory( 'SqliteMock', $p ); + return Database::factory( 'SqliteMock', $p ); } function query( $sql, $fname = '', $tempIgnore = false ) { diff --git a/tests/phpunit/includes/db/DatabaseTest.php b/tests/phpunit/includes/db/DatabaseTest.php index 0730529bd6..606a20989c 100644 --- a/tests/phpunit/includes/db/DatabaseTest.php +++ b/tests/phpunit/includes/db/DatabaseTest.php @@ -2,11 +2,11 @@ /** * @group Database - * @group DatabaseBase + * @group Database */ class DatabaseTest extends MediaWikiTestCase { /** - * @var DatabaseBase + * @var Database */ protected $db; @@ -23,9 +23,11 @@ class DatabaseTest extends MediaWikiTestCase { $this->dropFunctions(); $this->functionTest = false; } + $this->db->restoreFlags( IDatabase::RESTORE_INITIAL ); } + /** - * @covers DatabaseBase::dropTable + * @covers Database::dropTable */ public function testAddQuotesNull() { $check = "NULL"; @@ -67,21 +69,26 @@ class DatabaseTest extends MediaWikiTestCase { } private function getSharedTableName( $table, $database, $prefix, $format = 'quoted' ) { - global $wgSharedDB, $wgSharedTables, $wgSharedPrefix; - - $oldName = $wgSharedDB; - $oldTables = $wgSharedTables; - $oldPrefix = $wgSharedPrefix; + global $wgSharedDB, $wgSharedTables, $wgSharedPrefix, $wgSharedSchema; - $wgSharedDB = $database; - $wgSharedTables = [ $table ]; - $wgSharedPrefix = $prefix; + $this->db->setTableAliases( [ + $table => [ + 'dbname' => $database, + 'schema' => null, + 'prefix' => $prefix + ] + ] ); $ret = $this->db->tableName( $table, $format ); - $wgSharedDB = $oldName; - $wgSharedTables = $oldTables; - $wgSharedPrefix = $oldPrefix; + $this->db->setTableAliases( array_fill_keys( + $wgSharedDB ? $wgSharedTables : [], + [ + 'dbname' => $wgSharedDB, + 'schema' => $wgSharedSchema, + 'prefix' => $wgSharedPrefix + ] + ) ); return $ret; } @@ -168,47 +175,6 @@ class DatabaseTest extends MediaWikiTestCase { ); } - public function testFillPreparedEmpty() { - $sql = $this->db->fillPrepared( - 'SELECT * FROM interwiki', [] ); - $this->assertEquals( - "SELECT * FROM interwiki", - $sql ); - } - - public function testFillPreparedQuestion() { - $sql = $this->db->fillPrepared( - 'SELECT * FROM cur WHERE cur_namespace=? AND cur_title=?', - [ 4, "Snicker's_paradox" ] ); - - $check = "SELECT * FROM cur WHERE cur_namespace='4' AND cur_title='Snicker''s_paradox'"; - if ( $this->db->getType() === 'mysql' ) { - $check = "SELECT * FROM cur WHERE cur_namespace='4' AND cur_title='Snicker\'s_paradox'"; - } - $this->assertEquals( $check, $sql ); - } - - public function testFillPreparedBang() { - $sql = $this->db->fillPrepared( - 'SELECT user_id FROM ! WHERE user_name=?', - [ '"user"', "Slash's Dot" ] ); - - $check = "SELECT user_id FROM \"user\" WHERE user_name='Slash''s Dot'"; - if ( $this->db->getType() === 'mysql' ) { - $check = "SELECT user_id FROM \"user\" WHERE user_name='Slash\'s Dot'"; - } - $this->assertEquals( $check, $sql ); - } - - public function testFillPreparedRaw() { - $sql = $this->db->fillPrepared( - "SELECT * FROM cur WHERE cur_title='This_\\&_that,_WTF\\?\\!'", - [ '"user"', "Slash's Dot" ] ); - $this->assertEquals( - "SELECT * FROM cur WHERE cur_title='This_&_that,_WTF?!'", - $sql ); - } - public function testStoredFunctions() { if ( !in_array( wfGetDB( DB_MASTER )->getType(), [ 'mysql', 'postgres' ] ) ) { $this->markTestSkipped( 'MySQL or Postgres required' ); @@ -225,7 +191,7 @@ class DatabaseTest extends MediaWikiTestCase { private function dropFunctions() { $this->db->query( 'DROP FUNCTION IF EXISTS mw_test_function' - . ( $this->db->getType() == 'postgres' ? '()' : '' ) + . ( $this->db->getType() == 'postgres' ? '()' : '' ) ); } @@ -239,25 +205,209 @@ class DatabaseTest extends MediaWikiTestCase { $db = $this->db; $db->setFlag( DBO_TRX ); + $called = false; $flagSet = null; - $db->onTransactionIdle( function() use ( $db, &$flagSet ) { - $flagSet = $db->getFlag( DBO_TRX ); - } ); + $db->onTransactionIdle( + function () use ( $db, &$flagSet, &$called ) { + $called = true; + $flagSet = $db->getFlag( DBO_TRX ); + }, + __METHOD__ + ); $this->assertFalse( $flagSet, 'DBO_TRX off in callback' ); $this->assertTrue( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' ); + $this->assertTrue( $called, 'Callback reached' ); $db->clearFlag( DBO_TRX ); $flagSet = null; - $db->onTransactionIdle( function() use ( $db, &$flagSet ) { - $flagSet = $db->getFlag( DBO_TRX ); - } ); + $db->onTransactionIdle( + function () use ( $db, &$flagSet ) { + $flagSet = $db->getFlag( DBO_TRX ); + }, + __METHOD__ + ); $this->assertFalse( $flagSet, 'DBO_TRX off in callback' ); $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' ); $db->clearFlag( DBO_TRX ); - $db->onTransactionIdle( function() use ( $db ) { + $db->onTransactionIdle( + function () use ( $db ) { + $db->setFlag( DBO_TRX ); + }, + __METHOD__ + ); + $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' ); + } + + public function testTransactionResolution() { + $db = $this->db; + + $db->clearFlag( DBO_TRX ); + $db->begin( __METHOD__ ); + $called = false; + $db->onTransactionResolution( function () use ( $db, &$called ) { + $called = true; + $db->setFlag( DBO_TRX ); + } ); + $db->commit( __METHOD__ ); + $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' ); + $this->assertTrue( $called, 'Callback reached' ); + + $db->clearFlag( DBO_TRX ); + $db->begin( __METHOD__ ); + $called = false; + $db->onTransactionResolution( function () use ( $db, &$called ) { + $called = true; $db->setFlag( DBO_TRX ); } ); + $db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS ); $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' ); + $this->assertTrue( $called, 'Callback reached' ); + } + + /** + * @covers Database::setTransactionListener() + */ + public function testTransactionListener() { + $db = $this->db; + + $db->setTransactionListener( 'ping', function () use ( $db, &$called ) { + $called = true; + } ); + + $called = false; + $db->begin( __METHOD__ ); + $db->commit( __METHOD__ ); + $this->assertTrue( $called, 'Callback reached' ); + + $called = false; + $db->begin( __METHOD__ ); + $db->commit( __METHOD__ ); + $this->assertTrue( $called, 'Callback still reached' ); + + $called = false; + $db->begin( __METHOD__ ); + $db->rollback( __METHOD__ ); + $this->assertTrue( $called, 'Callback reached' ); + + $db->setTransactionListener( 'ping', null ); + $called = false; + $db->begin( __METHOD__ ); + $db->commit( __METHOD__ ); + $this->assertFalse( $called, 'Callback not reached' ); + } + + /** + * @covers Database::flushSnapshot() + */ + public function testFlushSnapshot() { + $db = $this->db; + + $db->flushSnapshot( __METHOD__ ); // ok + $db->flushSnapshot( __METHOD__ ); // ok + + $db->setFlag( DBO_TRX, $db::REMEMBER_PRIOR ); + $db->query( 'SELECT 1', __METHOD__ ); + $this->assertTrue( (bool)$db->trxLevel(), "Transaction started." ); + $db->flushSnapshot( __METHOD__ ); // ok + $db->restoreFlags( $db::RESTORE_PRIOR ); + + $this->assertFalse( (bool)$db->trxLevel(), "Transaction cleared." ); + } + + public function testGetScopedLock() { + $db = $this->db; + + $db->setFlag( DBO_TRX ); + try { + $this->badLockingMethodImplicit( $db ); + } catch ( RunTimeException $e ) { + $this->assertTrue( $db->trxLevel() > 0, "Transaction not committed." ); + } + $db->clearFlag( DBO_TRX ); + $db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS ); + $this->assertTrue( $db->lockIsFree( 'meow', __METHOD__ ) ); + + try { + $this->badLockingMethodExplicit( $db ); + } catch ( RunTimeException $e ) { + $this->assertTrue( $db->trxLevel() > 0, "Transaction not committed." ); + } + $db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS ); + $this->assertTrue( $db->lockIsFree( 'meow', __METHOD__ ) ); + } + + private function badLockingMethodImplicit( IDatabase $db ) { + $lock = $db->getScopedLockAndFlush( 'meow', __METHOD__, 1 ); + $db->query( "SELECT 1" ); // trigger DBO_TRX + throw new RunTimeException( "Uh oh!" ); + } + + private function badLockingMethodExplicit( IDatabase $db ) { + $lock = $db->getScopedLockAndFlush( 'meow', __METHOD__, 1 ); + $db->begin( __METHOD__ ); + throw new RunTimeException( "Uh oh!" ); + } + + /** + * @covers Database::getFlag( + * @covers Database::setFlag() + * @covers Database::restoreFlags() + */ + public function testFlagSetting() { + $db = $this->db; + $origTrx = $db->getFlag( DBO_TRX ); + $origSsl = $db->getFlag( DBO_SSL ); + + $origTrx + ? $db->clearFlag( DBO_TRX, $db::REMEMBER_PRIOR ) + : $db->setFlag( DBO_TRX, $db::REMEMBER_PRIOR ); + $this->assertEquals( !$origTrx, $db->getFlag( DBO_TRX ) ); + + $origSsl + ? $db->clearFlag( DBO_SSL, $db::REMEMBER_PRIOR ) + : $db->setFlag( DBO_SSL, $db::REMEMBER_PRIOR ); + $this->assertEquals( !$origSsl, $db->getFlag( DBO_SSL ) ); + + $db->restoreFlags( $db::RESTORE_INITIAL ); + $this->assertEquals( $origTrx, $db->getFlag( DBO_TRX ) ); + $this->assertEquals( $origSsl, $db->getFlag( DBO_SSL ) ); + + $origTrx + ? $db->clearFlag( DBO_TRX, $db::REMEMBER_PRIOR ) + : $db->setFlag( DBO_TRX, $db::REMEMBER_PRIOR ); + $origSsl + ? $db->clearFlag( DBO_SSL, $db::REMEMBER_PRIOR ) + : $db->setFlag( DBO_SSL, $db::REMEMBER_PRIOR ); + + $db->restoreFlags(); + $this->assertEquals( $origSsl, $db->getFlag( DBO_SSL ) ); + $this->assertEquals( !$origTrx, $db->getFlag( DBO_TRX ) ); + + $db->restoreFlags(); + $this->assertEquals( $origSsl, $db->getFlag( DBO_SSL ) ); + $this->assertEquals( $origTrx, $db->getFlag( DBO_TRX ) ); + } + + /** + * @covers Database::tablePrefix() + * @covers Database::dbSchema() + */ + public function testMutators() { + $old = $this->db->tablePrefix(); + $this->assertType( 'string', $old, 'Prefix is string' ); + $this->assertEquals( $old, $this->db->tablePrefix(), "Prefix unchanged" ); + $this->assertEquals( $old, $this->db->tablePrefix( 'xxx' ) ); + $this->assertEquals( 'xxx', $this->db->tablePrefix(), "Prefix set" ); + $this->db->tablePrefix( $old ); + $this->assertNotEquals( 'xxx', $this->db->tablePrefix() ); + + $old = $this->db->dbSchema(); + $this->assertType( 'string', $old, 'Schema is string' ); + $this->assertEquals( $old, $this->db->dbSchema(), "Schema unchanged" ); + $this->assertEquals( $old, $this->db->dbSchema( 'xxx' ) ); + $this->assertEquals( 'xxx', $this->db->dbSchema(), "Schema set" ); + $this->db->dbSchema( $old ); + $this->assertNotEquals( 'xxx', $this->db->dbSchema() ); } } diff --git a/tests/phpunit/includes/db/DatabaseTestHelper.php b/tests/phpunit/includes/db/DatabaseTestHelper.php index aa8b8e8f0f..c5603c4093 100644 --- a/tests/phpunit/includes/db/DatabaseTestHelper.php +++ b/tests/phpunit/includes/db/DatabaseTestHelper.php @@ -1,10 +1,10 @@ testName = $testName; + + $this->profiler = new ProfilerStub( [] ); + $this->trxProfiler = new TransactionProfiler(); + $this->cliMode = isset( $opts['cliMode'] ) ? $opts['cliMode'] : true; + $this->connLogger = new \Psr\Log\NullLogger(); + $this->queryLogger = new \Psr\Log\NullLogger(); + $this->errorLogger = function ( Exception $e ) { + wfWarn( get_class( $e ) . ": {$e->getMessage()}" ); + }; + $this->currentDomain = DatabaseDomain::newUnspecified(); } /** @@ -44,6 +57,13 @@ class DatabaseTestHelper extends DatabaseBase { $this->tablesExists = (array)$tablesExists; } + /** + * @param mixed $res Use an array of row arrays to set row result + */ + public function forceNextResult( $res ) { + $this->nextResult = $res; + } + protected function addSql( $sql ) { // clean up spaces before and after some words and the whole string $this->lastSqls[] = trim( preg_replace( @@ -78,6 +98,11 @@ class DatabaseTestHelper extends DatabaseBase { } public function tableExists( $table, $fname = __METHOD__ ) { + $tableRaw = $this->tableName( $table, 'raw' ); + if ( isset( $this->mSessionTempTables[$tableRaw] ) ) { + return true; // already known to exist + } + $this->checkFunctionName( $fname ); return in_array( $table, (array)$this->tablesExists ); @@ -136,7 +161,7 @@ class DatabaseTestHelper extends DatabaseBase { return false; } - function indexInfo( $table, $index, $fname = 'DatabaseBase::indexInfo' ) { + function indexInfo( $table, $index, $fname = 'Database::indexInfo' ) { return false; } @@ -160,11 +185,19 @@ class DatabaseTestHelper extends DatabaseBase { return true; } + function ping( &$rtt = null ) { + $rtt = 0.0; + return true; + } + protected function closeConnection() { return false; } protected function doQuery( $sql ) { - return []; + $res = $this->nextResult; + $this->nextResult = []; + + return new FakeResultWrapper( $res ); } } diff --git a/tests/phpunit/includes/db/LBFactoryTest.php b/tests/phpunit/includes/db/LBFactoryTest.php index 3562ed822a..d8773f8ad2 100644 --- a/tests/phpunit/includes/db/LBFactoryTest.php +++ b/tests/phpunit/includes/db/LBFactoryTest.php @@ -43,7 +43,7 @@ class LBFactoryTest extends MediaWikiTestCase { ]; $this->hideDeprecated( '$wgLBFactoryConf must be updated. See RELEASE-NOTES for details' ); - $result = LBFactory::getLBFactoryClass( $config ); + $result = MWLBFactory::getLBFactoryClass( $config ); $this->assertEquals( $expected, $result ); } @@ -54,14 +54,26 @@ class LBFactoryTest extends MediaWikiTestCase { [ 'LBFactorySimple', 'LBFactory_Simple' ], [ 'LBFactorySingle', 'LBFactory_Single' ], [ 'LBFactoryMulti', 'LBFactory_Multi' ], - [ 'LBFactoryFake', 'LBFactory_Fake' ], ]; } public function testLBFactorySimpleServer() { - $this->setMwGlobals( 'wgDBservers', false ); + global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir; - $factory = new LBFactorySimple( [] ); + $servers = [ + [ + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => 0, + 'flags' => DBO_TRX // REPEATABLE-READ for consistency + ], + ]; + + $factory = new LBFactorySimple( [ 'servers' => $servers ] ); $lb = $factory->getMainLB(); $dbw = $lb->getConnection( DB_MASTER ); @@ -75,48 +87,57 @@ class LBFactoryTest extends MediaWikiTestCase { } public function testLBFactorySimpleServers() { - global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype; + global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir; - $this->setMwGlobals( 'wgDBservers', [ + $servers = [ [ // master - 'host' => $wgDBserver, - 'dbname' => $wgDBname, - 'user' => $wgDBuser, - 'password' => $wgDBpassword, - 'type' => $wgDBtype, - 'load' => 0, - 'flags' => DBO_TRX // REPEATABLE-READ for consistency + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => 0, + 'flags' => DBO_TRX // REPEATABLE-READ for consistency ], [ // emulated slave - 'host' => $wgDBserver, - 'dbname' => $wgDBname, - 'user' => $wgDBuser, - 'password' => $wgDBpassword, - 'type' => $wgDBtype, - 'load' => 100, - 'flags' => DBO_TRX // REPEATABLE-READ for consistency + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => 100, + 'flags' => DBO_TRX // REPEATABLE-READ for consistency ] - ] ); + ]; - $factory = new LBFactorySimple( [ 'loadMonitorClass' => 'LoadMonitorNull' ] ); + $factory = new LBFactorySimple( [ + 'servers' => $servers, + 'loadMonitorClass' => 'LoadMonitorNull' + ] ); $lb = $factory->getMainLB(); $dbw = $lb->getConnection( DB_MASTER ); $this->assertTrue( $dbw->getLBInfo( 'master' ), 'master shows as master' ); $this->assertEquals( - $wgDBserver, $dbw->getLBInfo( 'clusterMasterHost' ), 'cluster master set' ); + ( $wgDBserver != '' ) ? $wgDBserver : 'localhost', + $dbw->getLBInfo( 'clusterMasterHost' ), + 'cluster master set' ); $dbr = $lb->getConnection( DB_SLAVE ); - $this->assertTrue( $dbr->getLBInfo( 'slave' ), 'slave shows as slave' ); + $this->assertTrue( $dbr->getLBInfo( 'replica' ), 'slave shows as slave' ); $this->assertEquals( - $wgDBserver, $dbr->getLBInfo( 'clusterMasterHost' ), 'cluster master set' ); + ( $wgDBserver != '' ) ? $wgDBserver : 'localhost', + $dbr->getLBInfo( 'clusterMasterHost' ), + 'cluster master set' ); $factory->shutdown(); $lb->closeAll(); } public function testLBFactoryMulti() { - global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype; + global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir; $factory = new LBFactoryMulti( [ 'sectionsByDB' => [], @@ -131,6 +152,7 @@ class LBFactoryTest extends MediaWikiTestCase { 'user' => $wgDBuser, 'password' => $wgDBpassword, 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, 'flags' => DBO_DEFAULT ], 'hostsByName' => [ @@ -145,7 +167,7 @@ class LBFactoryTest extends MediaWikiTestCase { $this->assertTrue( $dbw->getLBInfo( 'master' ), 'master shows as master' ); $dbr = $lb->getConnection( DB_SLAVE ); - $this->assertTrue( $dbr->getLBInfo( 'slave' ), 'slave shows as slave' ); + $this->assertTrue( $dbr->getLBInfo( 'replica' ), 'slave shows as slave' ); $factory->shutdown(); $lb->closeAll(); @@ -155,25 +177,31 @@ class LBFactoryTest extends MediaWikiTestCase { // (a) First HTTP request $mPos = new MySQLMasterPos( 'db1034-bin.000976', '843431247' ); + $now = microtime( true ); $mockDB = $this->getMockBuilder( 'DatabaseMysql' ) ->disableOriginalConstructor() ->getMock(); - $mockDB->expects( $this->any() ) - ->method( 'doneWrites' )->will( $this->returnValue( true ) ); - $mockDB->expects( $this->any() ) - ->method( 'getMasterPos' )->will( $this->returnValue( $mPos ) ); + $mockDB->method( 'writesOrCallbacksPending' )->willReturn( true ); + $mockDB->method( 'lastDoneWrites' )->willReturn( $now ); + $mockDB->method( 'getMasterPos' )->willReturn( $mPos ); $lb = $this->getMockBuilder( 'LoadBalancer' ) ->disableOriginalConstructor() ->getMock(); - $lb->expects( $this->any() ) - ->method( 'getConnection' )->will( $this->returnValue( $mockDB ) ); - $lb->expects( $this->any() ) - ->method( 'getServerCount' )->will( $this->returnValue( 2 ) ); - $lb->expects( $this->any() ) - ->method( 'parentInfo' )->will( $this->returnValue( [ 'id' => "main-DEFAULT" ] ) ); - $lb->expects( $this->any() ) - ->method( 'getAnyOpenConnection' )->will( $this->returnValue( $mockDB ) ); + $lb->method( 'getConnection' )->willReturn( $mockDB ); + $lb->method( 'getServerCount' )->willReturn( 2 ); + $lb->method( 'parentInfo' )->willReturn( [ 'id' => "main-DEFAULT" ] ); + $lb->method( 'getAnyOpenConnection' )->willReturn( $mockDB ); + $lb->method( 'hasOrMadeRecentMasterChanges' )->will( $this->returnCallback( + function () use ( $mockDB ) { + $p = 0; + $p |= call_user_func( [ $mockDB, 'writesOrCallbacksPending' ] ); + $p |= call_user_func( [ $mockDB, 'lastDoneWrites' ] ); + + return (bool)$p; + } + ) ); + $lb->method( 'getMasterPos' )->willReturn( $mPos ); $bag = new HashBagOStuff(); $cp = new ChronologyProtector( @@ -184,7 +212,8 @@ class LBFactoryTest extends MediaWikiTestCase { ] ); - $mockDB->expects( $this->exactly( 2 ) )->method( 'doneWrites' ); + $mockDB->expects( $this->exactly( 2 ) )->method( 'writesOrCallbacksPending' ); + $mockDB->expects( $this->exactly( 2 ) )->method( 'lastDoneWrites' ); // Nothing to wait for $cp->initLB( $lb ); @@ -210,4 +239,186 @@ class LBFactoryTest extends MediaWikiTestCase { $cp->shutdownLB( $lb ); $cp->shutdown(); } + + private function newLBFactoryMulti( array $baseOverride = [], array $serverOverride = [] ) { + global $wgDBserver, $wgDBuser, $wgDBpassword, $wgDBname, $wgDBtype, $wgSQLiteDataDir; + + return new LBFactoryMulti( $baseOverride + [ + 'sectionsByDB' => [], + 'sectionLoads' => [ + 'DEFAULT' => [ + 'test-db1' => 1, + ], + ], + 'serverTemplate' => $serverOverride + [ + 'dbname' => $wgDBname, + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'flags' => DBO_DEFAULT + ], + 'hostsByName' => [ + 'test-db1' => $wgDBserver, + ], + 'loadMonitorClass' => 'LoadMonitorNull', + 'localDomain' => wfWikiID() + ] ); + } + + public function testNiceDomains() { + global $wgDBname, $wgDBtype; + + if ( $wgDBtype === 'sqlite' ) { + $tmpDir = $this->getNewTempDirectory(); + $dbPath = "$tmpDir/unit_test_db.sqlite"; + file_put_contents( $dbPath, '' ); + $tempFsFile = new TempFSFile( $dbPath ); + $tempFsFile->autocollect(); + } else { + $dbPath = null; + } + + $factory = $this->newLBFactoryMulti( + [], + [ 'dbFilePath' => $dbPath ] + ); + $lb = $factory->getMainLB(); + + if ( $wgDBtype !== 'sqlite' ) { + $db = $lb->getConnectionRef( DB_MASTER ); + $this->assertEquals( + $wgDBname, + $db->getDomainID() + ); + unset( $db ); + } + + /** @var Database $db */ + $db = $lb->getConnection( DB_MASTER, [], '' ); + $lb->reuseConnection( $db ); // don't care + + $this->assertEquals( + '', + $db->getDomainID() + ); + + $this->assertEquals( + $this->quoteTable( $db, 'page' ), + $db->tableName( 'page' ), + "Correct full table name" + ); + + $this->assertEquals( + $this->quoteTable( $db, $wgDBname ) . '.' . $this->quoteTable( $db, 'page' ), + $db->tableName( "$wgDBname.page" ), + "Correct full table name" + ); + + $this->assertEquals( + $this->quoteTable( $db, 'nice_db' ) . '.' . $this->quoteTable( $db, 'page' ), + $db->tableName( 'nice_db.page' ), + "Correct full table name" + ); + + $factory->setDomainPrefix( 'my_' ); + $this->assertEquals( + '', + $db->getDomainID() + ); + $this->assertEquals( + $this->quoteTable( $db, 'my_page' ), + $db->tableName( 'page' ), + "Correct full table name" + ); + $this->assertEquals( + $this->quoteTable( $db, 'other_nice_db' ) . '.' . $this->quoteTable( $db, 'page' ), + $db->tableName( 'other_nice_db.page' ), + "Correct full table name" + ); + + $factory->closeAll(); + $factory->destroy(); + } + + public function testTrickyDomain() { + global $wgDBtype; + + if ( $wgDBtype === 'sqlite' ) { + $tmpDir = $this->getNewTempDirectory(); + $dbPath = "$tmpDir/unit_test_db.sqlite"; + file_put_contents( $dbPath, '' ); + $tempFsFile = new TempFSFile( $dbPath ); + $tempFsFile->autocollect(); + } else { + $dbPath = null; + } + + $dbname = 'unittest-domain'; + $factory = $this->newLBFactoryMulti( + [ 'localDomain' => $dbname ], + [ 'dbname' => $dbname, 'dbFilePath' => $dbPath ] + ); + $lb = $factory->getMainLB(); + /** @var Database $db */ + $db = $lb->getConnection( DB_MASTER, [], '' ); + $lb->reuseConnection( $db ); // don't care + + $this->assertEquals( + '', + $db->getDomainID() + ); + + $this->assertEquals( + $this->quoteTable( $db, 'page' ), + $db->tableName( 'page' ), + "Correct full table name" + ); + + $this->assertEquals( + $this->quoteTable( $db, $dbname ) . '.' . $this->quoteTable( $db, 'page' ), + $db->tableName( "$dbname.page" ), + "Correct full table name" + ); + + $this->assertEquals( + $this->quoteTable( $db, 'nice_db' ) . '.' . $this->quoteTable( $db, 'page' ), + $db->tableName( 'nice_db.page' ), + "Correct full table name" + ); + + $factory->setDomainPrefix( 'my_' ); + + $this->assertEquals( + $this->quoteTable( $db, 'my_page' ), + $db->tableName( 'page' ), + "Correct full table name" + ); + $this->assertEquals( + $this->quoteTable( $db, 'other_nice_db' ) . '.' . $this->quoteTable( $db, 'page' ), + $db->tableName( 'other_nice_db.page' ), + "Correct full table name" + ); + + \MediaWiki\suppressWarnings(); + $this->assertFalse( $db->selectDB( 'garbage-db' ) ); + \MediaWiki\restoreWarnings(); + + $this->assertEquals( + $this->quoteTable( $db, 'garbage-db' ) . '.' . $this->quoteTable( $db, 'page' ), + $db->tableName( 'garbage-db.page' ), + "Correct full table name" + ); + + $factory->closeAll(); + $factory->destroy(); + } + + private function quoteTable( Database $db, $table ) { + if ( $db->getType() === 'sqlite' ) { + return $table; + } else { + return $db->addIdentifierQuotes( $table ); + } + } } diff --git a/tests/phpunit/includes/debug/MWDebugTest.php b/tests/phpunit/includes/debug/MWDebugTest.php index 9c2bc750d4..5c65483150 100644 --- a/tests/phpunit/includes/debug/MWDebugTest.php +++ b/tests/phpunit/includes/debug/MWDebugTest.php @@ -4,20 +4,20 @@ class MWDebugTest extends MediaWikiTestCase { protected function setUp() { parent::setUp(); - // Make sure MWDebug class is enabled - static $MWDebugEnabled = false; - if ( !$MWDebugEnabled ) { - MWDebug::init(); - $MWDebugEnabled = true; - } /** Clear log before each test */ MWDebug::clearLog(); + } + + public static function setUpBeforeClass() { + parent::setUpBeforeClass(); + MWDebug::init(); MediaWiki\suppressWarnings(); } - protected function tearDown() { + public static function tearDownAfterClass() { + parent::tearDownAfterClass(); + MWDebug::deinit(); MediaWiki\restoreWarnings(); - parent::tearDown(); } /** diff --git a/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php b/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php index e29d2071c5..d6249bba46 100644 --- a/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php +++ b/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php @@ -23,9 +23,6 @@ namespace MediaWiki\Logger\Monolog; use MediaWikiTestCase; use Monolog\Logger; -// not available in the version of phpunit mw uses, so copied into repo -require_once __DIR__ . '/../../../phpunit/ConsecutiveParametersMatcher.php'; - class KafkaHandlerTest extends MediaWikiTestCase { protected function setUp() { @@ -58,6 +55,9 @@ class KafkaHandlerTest extends MediaWikiTestCase { $produce->expects( $this->once() ) ->method( 'setMessages' ) ->with( $expect, $this->anything(), $this->anything() ); + $produce->expects( $this->any() ) + ->method( 'send' ) + ->will( $this->returnValue( true ) ); $handler = new KafkaHandler( $produce, $options ); $handler->handle( [ @@ -89,6 +89,9 @@ class KafkaHandlerTest extends MediaWikiTestCase { $produce->expects( $this->any() ) ->method( 'getAvailablePartitions' ) ->will( $this->throwException( new \Kafka\Exception ) ); + $produce->expects( $this->any() ) + ->method( 'send' ) + ->will( $this->returnValue( true ) ); if ( $expectException ) { $this->setExpectedException( 'Kafka\Exception' ); @@ -147,6 +150,9 @@ class KafkaHandlerTest extends MediaWikiTestCase { ->will( $this->returnValue( [ 'A' ] ) ); $mockMethod = $produce->expects( $this->exactly( 2 ) ) ->method( 'setMessages' ); + $produce->expects( $this->any() ) + ->method( 'send' ) + ->will( $this->returnValue( true ) ); // evil hax \TestingAccessWrapper::newFromObject( $mockMethod )->matcher->parametersMatcher = new \PHPUnit_Framework_MockObject_Matcher_ConsecutiveParameters( [ @@ -181,6 +187,9 @@ class KafkaHandlerTest extends MediaWikiTestCase { $produce->expects( $this->once() ) ->method( 'setMessages' ) ->with( $this->anything(), $this->anything(), [ 'words', 'lines' ] ); + $produce->expects( $this->any() ) + ->method( 'send' ) + ->will( $this->returnValue( true ) ); $formatter = $this->getMock( 'Monolog\Formatter\FormatterInterface' ); $formatter->expects( $this->any() ) diff --git a/tests/phpunit/includes/debug/logger/monolog/LogstashFormatterTest.php b/tests/phpunit/includes/debug/logger/monolog/LogstashFormatterTest.php new file mode 100644 index 0000000000..8086b4bf23 --- /dev/null +++ b/tests/phpunit/includes/debug/logger/monolog/LogstashFormatterTest.php @@ -0,0 +1,55 @@ +format( $record ), true ); + foreach ( $expected as $key => $value ) { + $this->assertArrayHasKey( $key, $formatted ); + $this->assertSame( $value, $formatted[$key] ); + } + foreach ( $notExpected as $key ) { + $this->assertArrayNotHasKey( $key, $formatted ); + } + } + + public function provideV1() { + return [ + [ + [ 'extra' => [ 'foo' => 1 ], 'context' => [ 'bar' => 2 ] ], + [ 'foo' => 1, 'bar' => 2 ], + [ 'logstash_formatter_key_conflict' ], + ], + [ + [ 'extra' => [ 'url' => 1 ], 'context' => [ 'url' => 2 ] ], + [ 'url' => 1, 'c_url' => 2, 'logstash_formatter_key_conflict' => [ 'url' ] ], + [], + ], + [ + [ 'channel' => 'x', 'context' => [ 'channel' => 'y' ] ], + [ 'channel' => 'x', 'c_channel' => 'y', + 'logstash_formatter_key_conflict' => [ 'channel' ] ], + [], + ], + ]; + } + + public function testV1WithPrefix() { + $formatter = new LogstashFormatter( 'app', 'system', null, 'ctx_', LogstashFormatter::V1 ); + $record = [ 'extra' => [ 'url' => 1 ], 'context' => [ 'url' => 2 ] ]; + $formatted = json_decode( $formatter->format( $record ), true ); + $this->assertArrayHasKey( 'url', $formatted ); + $this->assertSame( 1, $formatted['url'] ); + $this->assertArrayHasKey( 'ctx_url', $formatted ); + $this->assertSame( 2, $formatted['ctx_url'] ); + $this->assertArrayNotHasKey( 'c_url', $formatted ); + } +} diff --git a/tests/phpunit/includes/deferred/DeferredUpdatesTest.php b/tests/phpunit/includes/deferred/DeferredUpdatesTest.php index ecc67b73ef..4227693a2e 100644 --- a/tests/phpunit/includes/deferred/DeferredUpdatesTest.php +++ b/tests/phpunit/includes/deferred/DeferredUpdatesTest.php @@ -5,10 +5,15 @@ class DeferredUpdatesTest extends MediaWikiTestCase { $this->setMwGlobals( 'wgCommandLineMode', false ); $updates = [ - '1' => 'deferred update 1', - '2' => 'deferred update 2', - '3' => 'deferred update 3', - '2-1' => 'deferred update 1 within deferred update 2', + '1' => "deferred update 1;\n", + '2' => "deferred update 2;\n", + '2-1' => "deferred update 1 within deferred update 2;\n", + '2-2' => "deferred update 2 within deferred update 2;\n", + '3' => "deferred update 3;\n", + '3-1' => "deferred update 1 within deferred update 3;\n", + '3-2' => "deferred update 2 within deferred update 3;\n", + '3-1-1' => "deferred update 1 within deferred update 1 within deferred update 3;\n", + '3-2-1' => "deferred update 1 within deferred update 2 with deferred update 3;\n", ]; DeferredUpdates::addCallableUpdate( function () use ( $updates ) { @@ -23,14 +28,41 @@ class DeferredUpdatesTest extends MediaWikiTestCase { echo $updates['2-1']; } ); + DeferredUpdates::addCallableUpdate( + function () use ( $updates ) { + echo $updates['2-2']; + } + ); } ); DeferredUpdates::addCallableUpdate( function () use ( $updates ) { - echo $updates[3]; + echo $updates['3']; + DeferredUpdates::addCallableUpdate( + function () use ( $updates ) { + echo $updates['3-1']; + DeferredUpdates::addCallableUpdate( + function () use ( $updates ) { + echo $updates['3-1-1']; + } + ); + } + ); + DeferredUpdates::addCallableUpdate( + function () use ( $updates ) { + echo $updates['3-2']; + DeferredUpdates::addCallableUpdate( + function () use ( $updates ) { + echo $updates['3-2-1']; + } + ); + } + ); } ); + $this->assertEquals( 3, DeferredUpdates::pendingUpdatesCount() ); + $this->expectOutputString( implode( '', $updates ) ); DeferredUpdates::doUpdates(); @@ -63,13 +95,20 @@ class DeferredUpdatesTest extends MediaWikiTestCase { public function testDoUpdatesCLI() { $this->setMwGlobals( 'wgCommandLineMode', true ); - $updates = [ - '1' => 'deferred update 1', - '2' => 'deferred update 2', - '2-1' => 'deferred update 1 within deferred update 2', - '3' => 'deferred update 3', + '1' => "deferred update 1;\n", + '2' => "deferred update 2;\n", + '2-1' => "deferred update 1 within deferred update 2;\n", + '2-2' => "deferred update 2 within deferred update 2;\n", + '3' => "deferred update 3;\n", + '3-1' => "deferred update 1 within deferred update 3;\n", + '3-2' => "deferred update 2 within deferred update 3;\n", + '3-1-1' => "deferred update 1 within deferred update 1 within deferred update 3;\n", + '3-2-1' => "deferred update 1 within deferred update 2 with deferred update 3;\n", ]; + + wfGetLBFactory()->commitMasterChanges( __METHOD__ ); // clear anything + DeferredUpdates::addCallableUpdate( function () use ( $updates ) { echo $updates['1']; @@ -83,11 +122,36 @@ class DeferredUpdatesTest extends MediaWikiTestCase { echo $updates['2-1']; } ); + DeferredUpdates::addCallableUpdate( + function () use ( $updates ) { + echo $updates['2-2']; + } + ); } ); DeferredUpdates::addCallableUpdate( function () use ( $updates ) { - echo $updates[3]; + echo $updates['3']; + DeferredUpdates::addCallableUpdate( + function () use ( $updates ) { + echo $updates['3-1']; + DeferredUpdates::addCallableUpdate( + function () use ( $updates ) { + echo $updates['3-1-1']; + } + ); + } + ); + DeferredUpdates::addCallableUpdate( + function () use ( $updates ) { + echo $updates['3-2']; + DeferredUpdates::addCallableUpdate( + function () use ( $updates ) { + echo $updates['3-2-1']; + } + ); + } + ); } ); diff --git a/tests/phpunit/includes/deferred/LinksUpdateTest.php b/tests/phpunit/includes/deferred/LinksUpdateTest.php index 3309352fde..9cc3ffdab7 100644 --- a/tests/phpunit/includes/deferred/LinksUpdateTest.php +++ b/tests/phpunit/includes/deferred/LinksUpdateTest.php @@ -369,10 +369,7 @@ class LinksUpdateTest extends MediaWikiLangTestCase { ) { $update = new LinksUpdate( $title, $parserOutput ); - // NOTE: make sure LinksUpdate does not generate warnings when called inside a transaction. - $update->beginTransaction(); $update->doUpdate(); - $update->commitTransaction(); $this->assertSelect( $table, $fields, $condition, $expectedRows ); return $update; diff --git a/tests/phpunit/includes/exception/MWExceptionTest.php b/tests/phpunit/includes/exception/MWExceptionTest.php index 0e87ffa42f..7c36f7d13d 100644 --- a/tests/phpunit/includes/exception/MWExceptionTest.php +++ b/tests/phpunit/includes/exception/MWExceptionTest.php @@ -173,7 +173,6 @@ class MWExceptionTest extends MediaWikiTestCase { * @dataProvider provideJsonSerializedKeys */ public function testJsonserializeexceptionKeys( $expectedKeyType, $exClass, $key ) { - # Make sure we log a backtrace: $this->setMwGlobals( [ 'wgLogExceptionBacktrace' => true ] ); @@ -235,7 +234,6 @@ class MWExceptionTest extends MediaWikiTestCase { MWExceptionHandler::jsonSerializeException( new Exception() ) ); $this->assertObjectNotHasAttribute( 'backtrace', $json ); - } } diff --git a/tests/phpunit/includes/filebackend/FileBackendTest.php b/tests/phpunit/includes/filebackend/FileBackendTest.php index 4aeddc6b6f..c3d31d1222 100644 --- a/tests/phpunit/includes/filebackend/FileBackendTest.php +++ b/tests/phpunit/includes/filebackend/FileBackendTest.php @@ -268,7 +268,7 @@ class FileBackendTest extends MediaWikiTestCase { public static function provider_testStore() { $cases = []; - $tmpName = TempFSFile::factory( "unittests_", 'txt' )->getPath(); + $tmpName = TempFSFile::factory( "unittests_", 'txt', wfTempDir() )->getPath(); $toPath = self::baseStorePath() . '/unittest-cont1/e/fun/obj1.txt'; $op = [ 'op' => 'store', 'src' => $tmpName, 'dst' => $toPath ]; $cases[] = [ $op ]; @@ -1139,16 +1139,16 @@ class FileBackendTest extends MediaWikiTestCase { $this->tearDownFiles(); $this->doTestStreamFile( $path, $content, $alreadyExists ); $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestStreamFile( $path, $content, $alreadyExists ); + $this->tearDownFiles(); } private function doTestStreamFile( $path, $content ) { $backendName = $this->backendClass(); - // Test doStreamFile() directly to avoid header madness - $class = new ReflectionClass( $this->backend ); - $method = $class->getMethod( 'doStreamFile' ); - $method->setAccessible( true ); - if ( $content !== null ) { $this->prepare( [ 'dir' => dirname( $path ) ] ); $status = $this->create( [ 'dst' => $path, 'content' => $content ] ); @@ -1156,18 +1156,19 @@ class FileBackendTest extends MediaWikiTestCase { "Creation of file at $path succeeded ($backendName)." ); ob_start(); - $method->invokeArgs( $this->backend, [ [ 'src' => $path ] ] ); + $this->backend->streamFile( [ 'src' => $path, 'headless' => 1, 'allowOB' => 1 ] ); $data = ob_get_contents(); ob_end_clean(); $this->assertEquals( $content, $data, "Correct content streamed from '$path'" ); } else { // 404 case ob_start(); - $method->invokeArgs( $this->backend, [ [ 'src' => $path ] ] ); + $this->backend->streamFile( [ 'src' => $path, 'headless' => 1, 'allowOB' => 1 ] ); $data = ob_get_contents(); ob_end_clean(); - $this->assertEquals( '', $data, "Correct content streamed from '$path' ($backendName)" ); + $this->assertRegExp( '#

    File not found

    #', $data, + "Correct content streamed from '$path' ($backendName)" ); } } @@ -1181,6 +1182,53 @@ class FileBackendTest extends MediaWikiTestCase { return $cases; } + public function testStreamFileRange() { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestStreamFileRange(); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestStreamFileRange(); + $this->tearDownFiles(); + } + + private function doTestStreamFileRange() { + $backendName = $this->backendClass(); + + $base = self::baseStorePath(); + $path = "$base/unittest-cont1/e/b/z/range_file.txt"; + $content = "0123456789ABCDEF"; + + $this->prepare( [ 'dir' => dirname( $path ) ] ); + $status = $this->create( [ 'dst' => $path, 'content' => $content ] ); + $this->assertGoodStatus( $status, + "Creation of file at $path succeeded ($backendName)." ); + + static $ranges = [ + 'bytes=0-0' => '0', + 'bytes=0-3' => '0123', + 'bytes=4-8' => '45678', + 'bytes=15-15' => 'F', + 'bytes=14-15' => 'EF', + 'bytes=-5' => 'BCDEF', + 'bytes=-1' => 'F', + 'bytes=10-16' => 'ABCDEF', + 'bytes=10-99' => 'ABCDEF', + ]; + + foreach ( $ranges as $range => $chunk ) { + ob_start(); + $this->backend->streamFile( [ 'src' => $path, 'headless' => 1, 'allowOB' => 1, + 'options' => [ 'range' => $range ] ] ); + $data = ob_get_contents(); + ob_end_clean(); + + $this->assertEquals( $chunk, $data, "Correct chunk streamed from '$path' for '$range'" ); + } + } + /** * @dataProvider provider_testGetFileContents * @covers FileBackend::getFileContents @@ -1516,7 +1564,7 @@ class FileBackendTest extends MediaWikiTestCase { [ "$base/unittest-cont1/e/a/z/some_file1.txt", true ], [ "$base/unittest-cont2/a/z/some_file2.txt", true ], # Specific to FS backend with no basePath field set - # array( "$base/unittest-cont3/a/z/some_file3.txt", false ), + # [ "$base/unittest-cont3/a/z/some_file3.txt", false ], ]; } @@ -1738,9 +1786,9 @@ class FileBackendTest extends MediaWikiTestCase { $fileBContents = 'g-jmq3gpqgt3qtg q3GT '; $fileCContents = 'eigna[ogmewt 3qt g3qg flew[ag'; - $tmpNameA = TempFSFile::factory( "unittests_", 'txt' )->getPath(); - $tmpNameB = TempFSFile::factory( "unittests_", 'txt' )->getPath(); - $tmpNameC = TempFSFile::factory( "unittests_", 'txt' )->getPath(); + $tmpNameA = TempFSFile::factory( "unittests_", 'txt', wfTempDir() )->getPath(); + $tmpNameB = TempFSFile::factory( "unittests_", 'txt', wfTempDir() )->getPath(); + $tmpNameC = TempFSFile::factory( "unittests_", 'txt', wfTempDir() )->getPath(); $this->addTmpFiles( [ $tmpNameA, $tmpNameB, $tmpNameC ] ); file_put_contents( $tmpNameA, $fileAContents ); file_put_contents( $tmpNameB, $fileBContents ); @@ -1866,7 +1914,7 @@ class FileBackendTest extends MediaWikiTestCase { // Does nothing ], [ 'force' => 1 ] ); - $this->assertNotEquals( [], $status->errors, "Operation had warnings" ); + $this->assertNotEquals( [], $status->getErrors(), "Operation had warnings" ); $this->assertEquals( true, $status->isOK(), "Operation batch succeeded" ); $this->assertEquals( 8, count( $status->success ), "Operation batch has correct success array" ); @@ -2323,25 +2371,25 @@ class FileBackendTest extends MediaWikiTestCase { for ( $i = 0; $i < 25; $i++ ) { $status = $this->backend->lockFiles( $paths, LockManager::LOCK_EX ); - $this->assertEquals( print_r( [], true ), print_r( $status->errors, true ), + $this->assertEquals( print_r( [], true ), print_r( $status->getErrors(), true ), "Locking of files succeeded ($backendName) ($i)." ); $this->assertEquals( true, $status->isOK(), "Locking of files succeeded with OK status ($backendName) ($i)." ); $status = $this->backend->lockFiles( $paths, LockManager::LOCK_SH ); - $this->assertEquals( print_r( [], true ), print_r( $status->errors, true ), + $this->assertEquals( print_r( [], true ), print_r( $status->getErrors(), true ), "Locking of files succeeded ($backendName) ($i)." ); $this->assertEquals( true, $status->isOK(), "Locking of files succeeded with OK status ($backendName) ($i)." ); $status = $this->backend->unlockFiles( $paths, LockManager::LOCK_SH ); - $this->assertEquals( print_r( [], true ), print_r( $status->errors, true ), + $this->assertEquals( print_r( [], true ), print_r( $status->getErrors(), true ), "Locking of files succeeded ($backendName) ($i)." ); $this->assertEquals( true, $status->isOK(), "Locking of files succeeded with OK status ($backendName) ($i)." ); $status = $this->backend->unlockFiles( $paths, LockManager::LOCK_EX ); - $this->assertEquals( print_r( [], true ), print_r( $status->errors, true ), + $this->assertEquals( print_r( [], true ), print_r( $status->getErrors(), true ), "Locking of files succeeded ($backendName). ($i)" ); $this->assertEquals( true, $status->isOK(), "Locking of files succeeded with OK status ($backendName) ($i)." ); @@ -2349,25 +2397,25 @@ class FileBackendTest extends MediaWikiTestCase { # # Flip the acquire/release ordering around ## $status = $this->backend->lockFiles( $paths, LockManager::LOCK_SH ); - $this->assertEquals( print_r( [], true ), print_r( $status->errors, true ), + $this->assertEquals( print_r( [], true ), print_r( $status->getErrors(), true ), "Locking of files succeeded ($backendName) ($i)." ); $this->assertEquals( true, $status->isOK(), "Locking of files succeeded with OK status ($backendName) ($i)." ); $status = $this->backend->lockFiles( $paths, LockManager::LOCK_EX ); - $this->assertEquals( print_r( [], true ), print_r( $status->errors, true ), + $this->assertEquals( print_r( [], true ), print_r( $status->getErrors(), true ), "Locking of files succeeded ($backendName) ($i)." ); $this->assertEquals( true, $status->isOK(), "Locking of files succeeded with OK status ($backendName) ($i)." ); $status = $this->backend->unlockFiles( $paths, LockManager::LOCK_EX ); - $this->assertEquals( print_r( [], true ), print_r( $status->errors, true ), + $this->assertEquals( print_r( [], true ), print_r( $status->getErrors(), true ), "Locking of files succeeded ($backendName). ($i)" ); $this->assertEquals( true, $status->isOK(), "Locking of files succeeded with OK status ($backendName) ($i)." ); $status = $this->backend->unlockFiles( $paths, LockManager::LOCK_SH ); - $this->assertEquals( print_r( [], true ), print_r( $status->errors, true ), + $this->assertEquals( print_r( [], true ), print_r( $status->getErrors(), true ), "Locking of files succeeded ($backendName) ($i)." ); $this->assertEquals( true, $status->isOK(), "Locking of files succeeded with OK status ($backendName) ($i)." ); @@ -2377,7 +2425,7 @@ class FileBackendTest extends MediaWikiTestCase { $sl = $this->backend->getScopedFileLocks( $paths, LockManager::LOCK_EX, $status ); $this->assertInstanceOf( 'ScopedLock', $sl, "Scoped locking of files succeeded ($backendName)." ); - $this->assertEquals( [], $status->errors, + $this->assertEquals( [], $status->getErrors(), "Scoped locking of files succeeded ($backendName)." ); $this->assertEquals( true, $status->isOK(), "Scoped locking of files succeeded with OK status ($backendName)." ); @@ -2385,7 +2433,7 @@ class FileBackendTest extends MediaWikiTestCase { ScopedLock::release( $sl ); $this->assertEquals( null, $sl, "Scoped unlocking of files succeeded ($backendName)." ); - $this->assertEquals( [], $status->errors, + $this->assertEquals( [], $status->getErrors(), "Scoped unlocking of files succeeded ($backendName)." ); $this->assertEquals( true, $status->isOK(), "Scoped unlocking of files succeeded with OK status ($backendName)." ); @@ -2599,7 +2647,7 @@ class FileBackendTest extends MediaWikiTestCase { } } - function assertGoodStatus( $status, $msg ) { - $this->assertEquals( print_r( [], 1 ), print_r( $status->errors, 1 ), $msg ); + function assertGoodStatus( StatusValue $status, $msg ) { + $this->assertEquals( print_r( [], 1 ), print_r( $status->getErrors(), 1 ), $msg ); } } diff --git a/tests/phpunit/includes/filerepo/MigrateFileRepoLayoutTest.php b/tests/phpunit/includes/filerepo/MigrateFileRepoLayoutTest.php index ed80c573a2..92a54faca1 100644 --- a/tests/phpunit/includes/filerepo/MigrateFileRepoLayoutTest.php +++ b/tests/phpunit/includes/filerepo/MigrateFileRepoLayoutTest.php @@ -59,7 +59,8 @@ class MigrateFileRepoLayoutTest extends MediaWikiTestCase { ->method( 'getRepo' ) ->will( $this->returnValue( $repoMock ) ); - $this->tmpFilepath = TempFSFile::factory( 'migratefilelayout-test-', 'png' )->getPath(); + $this->tmpFilepath = TempFSFile::factory( + 'migratefilelayout-test-', 'png', wfTempDir() )->getPath(); file_put_contents( $this->tmpFilepath, $this->text ); diff --git a/tests/phpunit/includes/filerepo/file/FileTest.php b/tests/phpunit/includes/filerepo/file/FileTest.php index c5fd369f14..6520610786 100644 --- a/tests/phpunit/includes/filerepo/file/FileTest.php +++ b/tests/phpunit/includes/filerepo/file/FileTest.php @@ -206,7 +206,7 @@ class FileTest extends MediaWikiMediaTestCase { ] ], [ [ 'supportsBucketing' => true, - 'tmpBucketedThumbCache' => [ 1024 => '/tmp/shouldnotexist' + rand() ], + 'tmpBucketedThumbCache' => [ 1024 => '/tmp/shouldnotexist' . rand() ], 'thumbnailBucket' => 1024, 'physicalWidth' => 2048, 'expectedPath' => 'fsFilePath', diff --git a/tests/phpunit/includes/htmlform/HTMLAutoCompleteSelectFieldTest.php b/tests/phpunit/includes/htmlform/HTMLAutoCompleteSelectFieldTest.php index fbabf7ffad..33e3a257d2 100644 --- a/tests/phpunit/includes/htmlform/HTMLAutoCompleteSelectFieldTest.php +++ b/tests/phpunit/includes/htmlform/HTMLAutoCompleteSelectFieldTest.php @@ -49,9 +49,9 @@ class HtmlAutoCompleteSelectFieldTest extends MediaWikiTestCase { */ function testOptionalSelectElement() { $params = [ - 'fieldname' => 'Test', - 'autocomplete' => $this->options, - 'options' => $this->options, + 'fieldname' => 'Test', + 'autocomplete-data' => $this->options, + 'options' => $this->options, ]; $field = new HTMLAutoCompleteSelectField( $params ); diff --git a/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php b/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php index dd5b58bd5e..f97716b9e5 100644 --- a/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php +++ b/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php @@ -51,7 +51,7 @@ class HtmlCheckMatrixTest extends MediaWikiTestCase { public function testValidateAllowsOnlyKnownTags() { $field = new HTMLCheckMatrix( self::$defaultOptions ); - $this->assertInternalType( 'string', $this->validate( $field, [ 'foo' ] ) ); + $this->assertInstanceOf( Message::class, $this->validate( $field, [ 'foo' ] ) ); } public function testValidateAcceptsPartialTagList() { diff --git a/tests/phpunit/includes/htmlform/HTMLRestrictionsFieldTest.php b/tests/phpunit/includes/htmlform/HTMLRestrictionsFieldTest.php new file mode 100644 index 0000000000..9ec4f97fd0 --- /dev/null +++ b/tests/phpunit/includes/htmlform/HTMLRestrictionsFieldTest.php @@ -0,0 +1,65 @@ + 'restrictions' ] ); + $this->assertNotEmpty( $field->getLabel(), 'has a default label' ); + $this->assertNotEmpty( $field->getHelpText(), 'has a default help text' ); + $this->assertEquals( MWRestrictions::newDefault(), $field->getDefault(), + 'defaults to the default MWRestrictions object' ); + + $field = new HTMLRestrictionsField( [ + 'fieldname' => 'restrictions', + 'label' => 'foo', + 'help' => 'bar', + 'default' => 'baz', + ] ); + $this->assertEquals( 'foo', $field->getLabel(), 'label can be customized' ); + $this->assertEquals( 'bar', $field->getHelpText(), 'help text can be customized' ); + $this->assertEquals( 'baz', $field->getDefault(), 'default can be customized' ); + } + + /** + * @dataProvider provideValidate + */ + public function testForm( $text, $value ) { + $form = HTMLForm::factory( 'ooui', [ + 'restrictions' => [ 'class' => HTMLRestrictionsField::class ], + ] ); + $request = new FauxRequest( [ 'wprestrictions' => $text ], true ); + $context = new DerivativeContext( RequestContext::getMain() ); + $context->setRequest( $request ); + $form->setContext( $context ); + $form->setTitle( Title::newFromText( 'Main Page' ) )->setSubmitCallback( function () { + return true; + } )->prepareForm(); + $status = $form->trySubmit(); + + if ( $status instanceof StatusValue ) { + $this->assertEquals( $value !== false, $status->isGood() ); + } elseif ( $value === false ) { + $this->assertNotSame( true, $status ); + } else { + $this->assertSame( true, $status ); + } + + if ( $value !== false ) { + $restrictions = $form->mFieldData['restrictions']; + $this->assertInstanceOf( MWRestrictions::class, $restrictions ); + $this->assertEquals( $value, $restrictions->toArray()['IPAddresses'] ); + } + + // sanity + $form->getHTML( $status ); + } + + public function provideValidate() { + return [ + // submitted text, value of 'IPAddresses' key or false for validation error + [ null, [ '0.0.0.0/0', '::/0' ] ], + [ '', [] ], + [ "1.2.3.4\n::/0", [ '1.2.3.4', '::/0' ] ], + [ "1.2.3.4\n::/x", false ], + ]; + } +} diff --git a/tests/phpunit/includes/http/HttpTest.php b/tests/phpunit/includes/http/HttpTest.php new file mode 100644 index 0000000000..7e98d1c069 --- /dev/null +++ b/tests/phpunit/includes/http/HttpTest.php @@ -0,0 +1,534 @@ +assertEquals( $expected, $ok, $msg ); + } + + public static function cookieDomains() { + return [ + [ false, "org" ], + [ false, ".org" ], + [ true, "wikipedia.org" ], + [ true, ".wikipedia.org" ], + [ false, "co.uk" ], + [ false, ".co.uk" ], + [ false, "gov.uk" ], + [ false, ".gov.uk" ], + [ true, "supermarket.uk" ], + [ false, "uk" ], + [ false, ".uk" ], + [ false, "127.0.0." ], + [ false, "127." ], + [ false, "127.0.0.1." ], + [ true, "127.0.0.1" ], + [ false, "333.0.0.1" ], + [ true, "example.com" ], + [ false, "example.com." ], + [ true, ".example.com" ], + + [ true, ".example.com", "www.example.com" ], + [ false, "example.com", "www.example.com" ], + [ true, "127.0.0.1", "127.0.0.1" ], + [ false, "127.0.0.1", "localhost" ], + ]; + } + + /** + * Test Http::isValidURI() + * @bug 27854 : Http::isValidURI is too lax + * @dataProvider provideURI + * @covers Http::isValidURI + */ + public function testIsValidUri( $expect, $URI, $message = '' ) { + $this->assertEquals( + $expect, + (bool)Http::isValidURI( $URI ), + $message + ); + } + + /** + * @covers Http::getProxy + */ + public function testGetProxy() { + $this->setMwGlobals( 'wgHTTPProxy', 'proxy.domain.tld' ); + $this->assertEquals( + 'proxy.domain.tld', + Http::getProxy() + ); + } + + /** + * Feeds URI to test a long regular expression in Http::isValidURI + */ + public static function provideURI() { + /** Format: 'boolean expectation', 'URI to test', 'Optional message' */ + return [ + [ false, '¿non sens before!! http://a', 'Allow anything before URI' ], + + # (http|https) - only two schemes allowed + [ true, 'http://www.example.org/' ], + [ true, 'https://www.example.org/' ], + [ true, 'http://www.example.org', 'URI without directory' ], + [ true, 'http://a', 'Short name' ], + [ true, 'http://étoile', 'Allow UTF-8 in hostname' ], # 'étoile' is french for 'star' + [ false, '\\host\directory', 'CIFS share' ], + [ false, 'gopher://host/dir', 'Reject gopher scheme' ], + [ false, 'telnet://host', 'Reject telnet scheme' ], + + # :\/\/ - double slashes + [ false, 'http//example.org', 'Reject missing colon in protocol' ], + [ false, 'http:/example.org', 'Reject missing slash in protocol' ], + [ false, 'http:example.org', 'Must have two slashes' ], + # Following fail since hostname can be made of anything + [ false, 'http:///example.org', 'Must have exactly two slashes, not three' ], + + # (\w+:{0,1}\w*@)? - optional user:pass + [ true, 'http://user@host', 'Username provided' ], + [ true, 'http://user:@host', 'Username provided, no password' ], + [ true, 'http://user:pass@host', 'Username and password provided' ], + + # (\S+) - host part is made of anything not whitespaces + // commented these out in order to remove @group Broken + // @todo are these valid tests? if so, fix Http::isValidURI so it can handle them + // [ false, 'http://!"èèè¿¿¿~~\'', 'hostname is made of any non whitespace' ], + // [ false, 'http://exam:ple.org/', 'hostname can not use colons!' ], + + # (:[0-9]+)? - port number + [ true, 'http://example.org:80/' ], + [ true, 'https://example.org:80/' ], + [ true, 'http://example.org:443/' ], + [ true, 'https://example.org:443/' ], + + # Part after the hostname is / or / with something else + [ true, 'http://example/#' ], + [ true, 'http://example/!' ], + [ true, 'http://example/:' ], + [ true, 'http://example/.' ], + [ true, 'http://example/?' ], + [ true, 'http://example/+' ], + [ true, 'http://example/=' ], + [ true, 'http://example/&' ], + [ true, 'http://example/%' ], + [ true, 'http://example/@' ], + [ true, 'http://example/-' ], + [ true, 'http://example//' ], + [ true, 'http://example/&' ], + + # Fragment + [ true, 'http://exam#ple.org', ], # This one is valid, really! + [ true, 'http://example.org:80#anchor' ], + [ true, 'http://example.org/?id#anchor' ], + [ true, 'http://example.org/?#anchor' ], + + [ false, 'http://a ¿non !!sens after', 'Allow anything after URI' ], + ]; + } + + /** + * Warning: + * + * These tests are for code that makes use of an artifact of how CURL + * handles header reporting on redirect pages, and will need to be + * rewritten when bug 29232 is taken care of (high-level handling of + * HTTP redirects). + */ + public function testRelativeRedirections() { + $h = MWHttpRequestTester::factory( 'http://oldsite/file.ext', [], __METHOD__ ); + + # Forge a Location header + $h->setRespHeaders( 'location', [ + 'http://newsite/file.ext', + '/newfile.ext', + ] + ); + # Verify we correctly fix the Location + $this->assertEquals( + 'http://newsite/newfile.ext', + $h->getFinalUrl(), + "Relative file path Location: interpreted as full URL" + ); + + $h->setRespHeaders( 'location', [ + 'https://oldsite/file.ext' + ] + ); + $this->assertEquals( + 'https://oldsite/file.ext', + $h->getFinalUrl(), + "Location to the HTTPS version of the site" + ); + + $h->setRespHeaders( 'location', [ + '/anotherfile.ext', + 'http://anotherfile/hoster.ext', + 'https://anotherfile/hoster.ext' + ] + ); + $this->assertEquals( + 'https://anotherfile/hoster.ext', + $h->getFinalUrl( "Relative file path Location: should keep the latest host and scheme!" ) + ); + } + + /** + * Constant values are from PHP 5.3.28 using cURL 7.24.0 + * @see https://secure.php.net/manual/en/curl.constants.php + * + * All constant values are present so that developers don’t need to remember + * to add them if added at a later date. The commented out constants were + * not found anywhere in the MediaWiki core code. + * + * Commented out constants that were not available in: + * HipHop VM 3.3.0 (rel) + * Compiler: heads/master-0-g08810d920dfff59e0774cf2d651f92f13a637175 + * Repo schema: 3214fc2c684a4520485f715ee45f33f2182324b1 + * Extension API: 20140829 + * + * Commented out constants that were removed in PHP 5.6.0 + * + * @covers CurlHttpRequest::execute + */ + public function provideCurlConstants() { + return [ + [ 'CURLAUTH_ANY' ], + [ 'CURLAUTH_ANYSAFE' ], + [ 'CURLAUTH_BASIC' ], + [ 'CURLAUTH_DIGEST' ], + [ 'CURLAUTH_GSSNEGOTIATE' ], + [ 'CURLAUTH_NTLM' ], + // [ 'CURLCLOSEPOLICY_CALLBACK' ], // removed in PHP 5.6.0 + // [ 'CURLCLOSEPOLICY_LEAST_RECENTLY_USED' ], // removed in PHP 5.6.0 + // [ 'CURLCLOSEPOLICY_LEAST_TRAFFIC' ], // removed in PHP 5.6.0 + // [ 'CURLCLOSEPOLICY_OLDEST' ], // removed in PHP 5.6.0 + // [ 'CURLCLOSEPOLICY_SLOWEST' ], // removed in PHP 5.6.0 + [ 'CURLE_ABORTED_BY_CALLBACK' ], + [ 'CURLE_BAD_CALLING_ORDER' ], + [ 'CURLE_BAD_CONTENT_ENCODING' ], + [ 'CURLE_BAD_FUNCTION_ARGUMENT' ], + [ 'CURLE_BAD_PASSWORD_ENTERED' ], + [ 'CURLE_COULDNT_CONNECT' ], + [ 'CURLE_COULDNT_RESOLVE_HOST' ], + [ 'CURLE_COULDNT_RESOLVE_PROXY' ], + [ 'CURLE_FAILED_INIT' ], + [ 'CURLE_FILESIZE_EXCEEDED' ], + [ 'CURLE_FILE_COULDNT_READ_FILE' ], + [ 'CURLE_FTP_ACCESS_DENIED' ], + [ 'CURLE_FTP_BAD_DOWNLOAD_RESUME' ], + [ 'CURLE_FTP_CANT_GET_HOST' ], + [ 'CURLE_FTP_CANT_RECONNECT' ], + [ 'CURLE_FTP_COULDNT_GET_SIZE' ], + [ 'CURLE_FTP_COULDNT_RETR_FILE' ], + [ 'CURLE_FTP_COULDNT_SET_ASCII' ], + [ 'CURLE_FTP_COULDNT_SET_BINARY' ], + [ 'CURLE_FTP_COULDNT_STOR_FILE' ], + [ 'CURLE_FTP_COULDNT_USE_REST' ], + [ 'CURLE_FTP_PORT_FAILED' ], + [ 'CURLE_FTP_QUOTE_ERROR' ], + [ 'CURLE_FTP_SSL_FAILED' ], + [ 'CURLE_FTP_USER_PASSWORD_INCORRECT' ], + [ 'CURLE_FTP_WEIRD_227_FORMAT' ], + [ 'CURLE_FTP_WEIRD_PASS_REPLY' ], + [ 'CURLE_FTP_WEIRD_PASV_REPLY' ], + [ 'CURLE_FTP_WEIRD_SERVER_REPLY' ], + [ 'CURLE_FTP_WEIRD_USER_REPLY' ], + [ 'CURLE_FTP_WRITE_ERROR' ], + [ 'CURLE_FUNCTION_NOT_FOUND' ], + [ 'CURLE_GOT_NOTHING' ], + [ 'CURLE_HTTP_NOT_FOUND' ], + [ 'CURLE_HTTP_PORT_FAILED' ], + [ 'CURLE_HTTP_POST_ERROR' ], + [ 'CURLE_HTTP_RANGE_ERROR' ], + [ 'CURLE_LDAP_CANNOT_BIND' ], + [ 'CURLE_LDAP_INVALID_URL' ], + [ 'CURLE_LDAP_SEARCH_FAILED' ], + [ 'CURLE_LIBRARY_NOT_FOUND' ], + [ 'CURLE_MALFORMAT_USER' ], + [ 'CURLE_OBSOLETE' ], + [ 'CURLE_OK' ], + [ 'CURLE_OPERATION_TIMEOUTED' ], + [ 'CURLE_OUT_OF_MEMORY' ], + [ 'CURLE_PARTIAL_FILE' ], + [ 'CURLE_READ_ERROR' ], + [ 'CURLE_RECV_ERROR' ], + [ 'CURLE_SEND_ERROR' ], + [ 'CURLE_SHARE_IN_USE' ], + // [ 'CURLE_SSH' ], // not present in HHVM 3.3.0-dev + [ 'CURLE_SSL_CACERT' ], + [ 'CURLE_SSL_CERTPROBLEM' ], + [ 'CURLE_SSL_CIPHER' ], + [ 'CURLE_SSL_CONNECT_ERROR' ], + [ 'CURLE_SSL_ENGINE_NOTFOUND' ], + [ 'CURLE_SSL_ENGINE_SETFAILED' ], + [ 'CURLE_SSL_PEER_CERTIFICATE' ], + [ 'CURLE_TELNET_OPTION_SYNTAX' ], + [ 'CURLE_TOO_MANY_REDIRECTS' ], + [ 'CURLE_UNKNOWN_TELNET_OPTION' ], + [ 'CURLE_UNSUPPORTED_PROTOCOL' ], + [ 'CURLE_URL_MALFORMAT' ], + [ 'CURLE_URL_MALFORMAT_USER' ], + [ 'CURLE_WRITE_ERROR' ], + [ 'CURLFTPAUTH_DEFAULT' ], + [ 'CURLFTPAUTH_SSL' ], + [ 'CURLFTPAUTH_TLS' ], + // [ 'CURLFTPMETHOD_MULTICWD' ], // not present in HHVM 3.3.0-dev + // [ 'CURLFTPMETHOD_NOCWD' ], // not present in HHVM 3.3.0-dev + // [ 'CURLFTPMETHOD_SINGLECWD' ], // not present in HHVM 3.3.0-dev + [ 'CURLFTPSSL_ALL' ], + [ 'CURLFTPSSL_CONTROL' ], + [ 'CURLFTPSSL_NONE' ], + [ 'CURLFTPSSL_TRY' ], + // [ 'CURLINFO_CERTINFO' ], // not present in HHVM 3.3.0-dev + [ 'CURLINFO_CONNECT_TIME' ], + [ 'CURLINFO_CONTENT_LENGTH_DOWNLOAD' ], + [ 'CURLINFO_CONTENT_LENGTH_UPLOAD' ], + [ 'CURLINFO_CONTENT_TYPE' ], + [ 'CURLINFO_EFFECTIVE_URL' ], + [ 'CURLINFO_FILETIME' ], + [ 'CURLINFO_HEADER_OUT' ], + [ 'CURLINFO_HEADER_SIZE' ], + [ 'CURLINFO_HTTP_CODE' ], + [ 'CURLINFO_NAMELOOKUP_TIME' ], + [ 'CURLINFO_PRETRANSFER_TIME' ], + [ 'CURLINFO_PRIVATE' ], + [ 'CURLINFO_REDIRECT_COUNT' ], + [ 'CURLINFO_REDIRECT_TIME' ], + // [ 'CURLINFO_REDIRECT_URL' ], // not present in HHVM 3.3.0-dev + [ 'CURLINFO_REQUEST_SIZE' ], + [ 'CURLINFO_SIZE_DOWNLOAD' ], + [ 'CURLINFO_SIZE_UPLOAD' ], + [ 'CURLINFO_SPEED_DOWNLOAD' ], + [ 'CURLINFO_SPEED_UPLOAD' ], + [ 'CURLINFO_SSL_VERIFYRESULT' ], + [ 'CURLINFO_STARTTRANSFER_TIME' ], + [ 'CURLINFO_TOTAL_TIME' ], + [ 'CURLMSG_DONE' ], + [ 'CURLM_BAD_EASY_HANDLE' ], + [ 'CURLM_BAD_HANDLE' ], + [ 'CURLM_CALL_MULTI_PERFORM' ], + [ 'CURLM_INTERNAL_ERROR' ], + [ 'CURLM_OK' ], + [ 'CURLM_OUT_OF_MEMORY' ], + [ 'CURLOPT_AUTOREFERER' ], + [ 'CURLOPT_BINARYTRANSFER' ], + [ 'CURLOPT_BUFFERSIZE' ], + [ 'CURLOPT_CAINFO' ], + [ 'CURLOPT_CAPATH' ], + // [ 'CURLOPT_CERTINFO' ], // not present in HHVM 3.3.0-dev + // [ 'CURLOPT_CLOSEPOLICY' ], // removed in PHP 5.6.0 + [ 'CURLOPT_CONNECTTIMEOUT' ], + [ 'CURLOPT_CONNECTTIMEOUT_MS' ], + [ 'CURLOPT_COOKIE' ], + [ 'CURLOPT_COOKIEFILE' ], + [ 'CURLOPT_COOKIEJAR' ], + [ 'CURLOPT_COOKIESESSION' ], + [ 'CURLOPT_CRLF' ], + [ 'CURLOPT_CUSTOMREQUEST' ], + [ 'CURLOPT_DNS_CACHE_TIMEOUT' ], + [ 'CURLOPT_DNS_USE_GLOBAL_CACHE' ], + [ 'CURLOPT_EGDSOCKET' ], + [ 'CURLOPT_ENCODING' ], + [ 'CURLOPT_FAILONERROR' ], + [ 'CURLOPT_FILE' ], + [ 'CURLOPT_FILETIME' ], + [ 'CURLOPT_FOLLOWLOCATION' ], + [ 'CURLOPT_FORBID_REUSE' ], + [ 'CURLOPT_FRESH_CONNECT' ], + [ 'CURLOPT_FTPAPPEND' ], + [ 'CURLOPT_FTPLISTONLY' ], + [ 'CURLOPT_FTPPORT' ], + [ 'CURLOPT_FTPSSLAUTH' ], + [ 'CURLOPT_FTP_CREATE_MISSING_DIRS' ], + // [ 'CURLOPT_FTP_FILEMETHOD' ], // not present in HHVM 3.3.0-dev + // [ 'CURLOPT_FTP_SKIP_PASV_IP' ], // not present in HHVM 3.3.0-dev + [ 'CURLOPT_FTP_SSL' ], + [ 'CURLOPT_FTP_USE_EPRT' ], + [ 'CURLOPT_FTP_USE_EPSV' ], + [ 'CURLOPT_HEADER' ], + [ 'CURLOPT_HEADERFUNCTION' ], + [ 'CURLOPT_HTTP200ALIASES' ], + [ 'CURLOPT_HTTPAUTH' ], + [ 'CURLOPT_HTTPGET' ], + [ 'CURLOPT_HTTPHEADER' ], + [ 'CURLOPT_HTTPPROXYTUNNEL' ], + [ 'CURLOPT_HTTP_VERSION' ], + [ 'CURLOPT_INFILE' ], + [ 'CURLOPT_INFILESIZE' ], + [ 'CURLOPT_INTERFACE' ], + [ 'CURLOPT_IPRESOLVE' ], + // [ 'CURLOPT_KEYPASSWD' ], // not present in HHVM 3.3.0-dev + [ 'CURLOPT_KRB4LEVEL' ], + [ 'CURLOPT_LOW_SPEED_LIMIT' ], + [ 'CURLOPT_LOW_SPEED_TIME' ], + [ 'CURLOPT_MAXCONNECTS' ], + [ 'CURLOPT_MAXREDIRS' ], + // [ 'CURLOPT_MAX_RECV_SPEED_LARGE' ], // not present in HHVM 3.3.0-dev + // [ 'CURLOPT_MAX_SEND_SPEED_LARGE' ], // not present in HHVM 3.3.0-dev + [ 'CURLOPT_NETRC' ], + [ 'CURLOPT_NOBODY' ], + [ 'CURLOPT_NOPROGRESS' ], + [ 'CURLOPT_NOSIGNAL' ], + [ 'CURLOPT_PORT' ], + [ 'CURLOPT_POST' ], + [ 'CURLOPT_POSTFIELDS' ], + [ 'CURLOPT_POSTQUOTE' ], + [ 'CURLOPT_POSTREDIR' ], + [ 'CURLOPT_PRIVATE' ], + [ 'CURLOPT_PROGRESSFUNCTION' ], + // [ 'CURLOPT_PROTOCOLS' ], // not present in HHVM 3.3.0-dev + [ 'CURLOPT_PROXY' ], + [ 'CURLOPT_PROXYAUTH' ], + [ 'CURLOPT_PROXYPORT' ], + [ 'CURLOPT_PROXYTYPE' ], + [ 'CURLOPT_PROXYUSERPWD' ], + [ 'CURLOPT_PUT' ], + [ 'CURLOPT_QUOTE' ], + [ 'CURLOPT_RANDOM_FILE' ], + [ 'CURLOPT_RANGE' ], + [ 'CURLOPT_READDATA' ], + [ 'CURLOPT_READFUNCTION' ], + // [ 'CURLOPT_REDIR_PROTOCOLS' ], // not present in HHVM 3.3.0-dev + [ 'CURLOPT_REFERER' ], + [ 'CURLOPT_RESUME_FROM' ], + [ 'CURLOPT_RETURNTRANSFER' ], + // [ 'CURLOPT_SSH_AUTH_TYPES' ], // not present in HHVM 3.3.0-dev + // [ 'CURLOPT_SSH_HOST_PUBLIC_KEY_MD5' ], // not present in HHVM 3.3.0-dev + // [ 'CURLOPT_SSH_PRIVATE_KEYFILE' ], // not present in HHVM 3.3.0-dev + // [ 'CURLOPT_SSH_PUBLIC_KEYFILE' ], // not present in HHVM 3.3.0-dev + [ 'CURLOPT_SSLCERT' ], + [ 'CURLOPT_SSLCERTPASSWD' ], + [ 'CURLOPT_SSLCERTTYPE' ], + [ 'CURLOPT_SSLENGINE' ], + [ 'CURLOPT_SSLENGINE_DEFAULT' ], + [ 'CURLOPT_SSLKEY' ], + [ 'CURLOPT_SSLKEYPASSWD' ], + [ 'CURLOPT_SSLKEYTYPE' ], + [ 'CURLOPT_SSLVERSION' ], + [ 'CURLOPT_SSL_CIPHER_LIST' ], + [ 'CURLOPT_SSL_VERIFYHOST' ], + [ 'CURLOPT_SSL_VERIFYPEER' ], + [ 'CURLOPT_STDERR' ], + [ 'CURLOPT_TCP_NODELAY' ], + [ 'CURLOPT_TIMECONDITION' ], + [ 'CURLOPT_TIMEOUT' ], + [ 'CURLOPT_TIMEOUT_MS' ], + [ 'CURLOPT_TIMEVALUE' ], + [ 'CURLOPT_TRANSFERTEXT' ], + [ 'CURLOPT_UNRESTRICTED_AUTH' ], + [ 'CURLOPT_UPLOAD' ], + [ 'CURLOPT_URL' ], + [ 'CURLOPT_USERAGENT' ], + [ 'CURLOPT_USERPWD' ], + [ 'CURLOPT_VERBOSE' ], + [ 'CURLOPT_WRITEFUNCTION' ], + [ 'CURLOPT_WRITEHEADER' ], + // [ 'CURLPROTO_ALL' ], // not present in HHVM 3.3.0-dev + // [ 'CURLPROTO_DICT' ], // not present in HHVM 3.3.0-dev + // [ 'CURLPROTO_FILE' ], // not present in HHVM 3.3.0-dev + // [ 'CURLPROTO_FTP' ], // not present in HHVM 3.3.0-dev + // [ 'CURLPROTO_FTPS' ], // not present in HHVM 3.3.0-dev + // [ 'CURLPROTO_HTTP' ], // not present in HHVM 3.3.0-dev + // [ 'CURLPROTO_HTTPS' ], // not present in HHVM 3.3.0-dev + // [ 'CURLPROTO_LDAP' ], // not present in HHVM 3.3.0-dev + // [ 'CURLPROTO_LDAPS' ], // not present in HHVM 3.3.0-dev + // [ 'CURLPROTO_SCP' ], // not present in HHVM 3.3.0-dev + // [ 'CURLPROTO_SFTP' ], // not present in HHVM 3.3.0-dev + // [ 'CURLPROTO_TELNET' ], // not present in HHVM 3.3.0-dev + // [ 'CURLPROTO_TFTP' ], // not present in HHVM 3.3.0-dev + [ 'CURLPROXY_HTTP' ], + // [ 'CURLPROXY_SOCKS4' ], // not present in HHVM 3.3.0-dev + [ 'CURLPROXY_SOCKS5' ], + // [ 'CURLSSH_AUTH_DEFAULT' ], // not present in HHVM 3.3.0-dev + // [ 'CURLSSH_AUTH_HOST' ], // not present in HHVM 3.3.0-dev + // [ 'CURLSSH_AUTH_KEYBOARD' ], // not present in HHVM 3.3.0-dev + // [ 'CURLSSH_AUTH_NONE' ], // not present in HHVM 3.3.0-dev + // [ 'CURLSSH_AUTH_PASSWORD' ], // not present in HHVM 3.3.0-dev + // [ 'CURLSSH_AUTH_PUBLICKEY' ], // not present in HHVM 3.3.0-dev + [ 'CURLVERSION_NOW' ], + [ 'CURL_HTTP_VERSION_1_0' ], + [ 'CURL_HTTP_VERSION_1_1' ], + [ 'CURL_HTTP_VERSION_NONE' ], + [ 'CURL_IPRESOLVE_V4' ], + [ 'CURL_IPRESOLVE_V6' ], + [ 'CURL_IPRESOLVE_WHATEVER' ], + [ 'CURL_NETRC_IGNORED' ], + [ 'CURL_NETRC_OPTIONAL' ], + [ 'CURL_NETRC_REQUIRED' ], + [ 'CURL_TIMECOND_IFMODSINCE' ], + [ 'CURL_TIMECOND_IFUNMODSINCE' ], + [ 'CURL_TIMECOND_LASTMOD' ], + [ 'CURL_VERSION_IPV6' ], + [ 'CURL_VERSION_KERBEROS4' ], + [ 'CURL_VERSION_LIBZ' ], + [ 'CURL_VERSION_SSL' ], + ]; + } + + /** + * Added this test based on an issue experienced with HHVM 3.3.0-dev + * where it did not define a cURL constant. + * + * @bug 70570 + * @dataProvider provideCurlConstants + */ + public function testCurlConstants( $value ) { + $this->assertTrue( defined( $value ), $value . ' not defined' ); + } +} + +/** + * Class to let us overwrite MWHttpRequest respHeaders variable + */ +class MWHttpRequestTester extends MWHttpRequest { + // function derived from the MWHttpRequest factory function but + // returns appropriate tester class here + public static function factory( $url, $options = null, $caller = __METHOD__ ) { + if ( !Http::$httpEngine ) { + Http::$httpEngine = function_exists( 'curl_init' ) ? 'curl' : 'php'; + } elseif ( Http::$httpEngine == 'curl' && !function_exists( 'curl_init' ) ) { + throw new DomainException( __METHOD__ . ': curl (http://php.net/curl) is not installed, but' . + 'Http::$httpEngine is set to "curl"' ); + } + + switch ( Http::$httpEngine ) { + case 'curl': + return new CurlHttpRequestTester( $url, $options, $caller ); + case 'php': + if ( !wfIniGetBool( 'allow_url_fopen' ) ) { + throw new DomainException( __METHOD__ . + ': allow_url_fopen needs to be enabled for pure PHP HTTP requests to work. ' + . 'If possible, curl should be used instead. See http://php.net/curl.' ); + } + + return new PhpHttpRequestTester( $url, $options, $caller ); + default: + } + } +} + +class CurlHttpRequestTester extends CurlHttpRequest { + function setRespHeaders( $name, $value ) { + $this->respHeaders[$name] = $value; + } +} + +class PhpHttpRequestTester extends PhpHttpRequest { + function setRespHeaders( $name, $value ) { + $this->respHeaders[$name] = $value; + } +} diff --git a/tests/phpunit/includes/import/ImportLinkCacheIntegrationTest.php b/tests/phpunit/includes/import/ImportLinkCacheIntegrationTest.php index 5e3c6268aa..8e06f9e968 100644 --- a/tests/phpunit/includes/import/ImportLinkCacheIntegrationTest.php +++ b/tests/phpunit/includes/import/ImportLinkCacheIntegrationTest.php @@ -1,4 +1,6 @@ value, - ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) + MediaWikiServices::getInstance()->getMainConfig() ); $importer->setDebug( true ); diff --git a/tests/phpunit/includes/import/ImportTest.php b/tests/phpunit/includes/import/ImportTest.php index 9d05d15256..53d91c6593 100644 --- a/tests/phpunit/includes/import/ImportTest.php +++ b/tests/phpunit/includes/import/ImportTest.php @@ -1,4 +1,5 @@ makeConfig( 'main' ) + MediaWikiServices::getInstance()->getMainConfig() ); $importer->doImport(); @@ -92,7 +93,7 @@ EOF $importer = new WikiImporter( $source, - ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) + MediaWikiServices::getInstance()->getMainConfig() ); $importer->setPageOutCallback( $callback ); $importer->doImport(); @@ -175,7 +176,7 @@ EOF $importer = new WikiImporter( $source, - ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) + MediaWikiServices::getInstance()->getMainConfig() ); $importer->setSiteInfoCallback( $callback ); $importer->doImport(); diff --git a/tests/phpunit/includes/installer/DatabaseUpdaterTest.php b/tests/phpunit/includes/installer/DatabaseUpdaterTest.php deleted file mode 100644 index 5e5e921ae6..0000000000 --- a/tests/phpunit/includes/installer/DatabaseUpdaterTest.php +++ /dev/null @@ -1,279 +0,0 @@ -setAppliedUpdates( "test", [] ); - $expected = "updatelist-test-" . time() . "0"; - $actual = $db->lastInsertData['ul_key']; - $this->assertEquals( $expected, $actual, var_export( $db->lastInsertData, true ) ); - $dbu->setAppliedUpdates( "test", [] ); - $expected = "updatelist-test-" . time() . "1"; - $actual = $db->lastInsertData['ul_key']; - $this->assertEquals( $expected, $actual, var_export( $db->lastInsertData, true ) ); - } -} - -class FakeDatabase extends DatabaseBase { - public $lastInsertTable; - public $lastInsertData; - - function __construct() { - } - - function clearFlag( $arg ) { - } - - function setFlag( $arg ) { - } - - public function insert( $table, $a, $fname = __METHOD__, $options = [] ) { - $this->lastInsertTable = $table; - $this->lastInsertData = $a; - } - - /** - * Get the type of the DBMS, as it appears in $wgDBtype. - * - * @return string - */ - function getType() { - // TODO: Implement getType() method. - } - - /** - * Open a connection to the database. Usually aborts on failure - * - * @param string $server Database server host - * @param string $user Database user name - * @param string $password Database user password - * @param string $dbName Database name - * @return bool - * @throws DBConnectionError - */ - function open( $server, $user, $password, $dbName ) { - // TODO: Implement open() method. - } - - /** - * Fetch the next row from the given result object, in object form. - * Fields can be retrieved with $row->fieldname, with fields acting like - * member variables. - * If no more rows are available, false is returned. - * - * @param ResultWrapper|stdClass $res Object as returned from DatabaseBase::query(), etc. - * @return stdClass|bool - * @throws DBUnexpectedError Thrown if the database returns an error - */ - function fetchObject( $res ) { - // TODO: Implement fetchObject() method. - } - - /** - * Fetch the next row from the given result object, in associative array - * form. Fields are retrieved with $row['fieldname']. - * If no more rows are available, false is returned. - * - * @param ResultWrapper $res Result object as returned from DatabaseBase::query(), etc. - * @return array|bool - * @throws DBUnexpectedError Thrown if the database returns an error - */ - function fetchRow( $res ) { - // TODO: Implement fetchRow() method. - } - - /** - * Get the number of rows in a result object - * - * @param mixed $res A SQL result - * @return int - */ - function numRows( $res ) { - // TODO: Implement numRows() method. - } - - /** - * Get the number of fields in a result object - * @see http://www.php.net/mysql_num_fields - * - * @param mixed $res A SQL result - * @return int - */ - function numFields( $res ) { - // TODO: Implement numFields() method. - } - - /** - * Get a field name in a result object - * @see http://www.php.net/mysql_field_name - * - * @param mixed $res A SQL result - * @param int $n - * @return string - */ - function fieldName( $res, $n ) { - // TODO: Implement fieldName() method. - } - - /** - * Get the inserted value of an auto-increment row - * - * The value inserted should be fetched from nextSequenceValue() - * - * Example: - * $id = $dbw->nextSequenceValue( 'page_page_id_seq' ); - * $dbw->insert( 'page', array( 'page_id' => $id ) ); - * $id = $dbw->insertId(); - * - * @return int - */ - function insertId() { - // TODO: Implement insertId() method. - } - - /** - * Change the position of the cursor in a result object - * @see http://www.php.net/mysql_data_seek - * - * @param mixed $res A SQL result - * @param int $row - */ - function dataSeek( $res, $row ) { - // TODO: Implement dataSeek() method. - } - - /** - * Get the last error number - * @see http://www.php.net/mysql_errno - * - * @return int - */ - function lastErrno() { - // TODO: Implement lastErrno() method. - } - - /** - * Get a description of the last error - * @see http://www.php.net/mysql_error - * - * @return string - */ - function lastError() { - // TODO: Implement lastError() method. - } - - /** - * mysql_fetch_field() wrapper - * Returns false if the field doesn't exist - * - * @param string $table Table name - * @param string $field Field name - * - * @return Field - */ - function fieldInfo( $table, $field ) { - // TODO: Implement fieldInfo() method. - } - - /** - * Get information about an index into an object - * @param string $table Table name - * @param string $index Index name - * @param string $fname Calling function name - * @return mixed Database-specific index description class or false if the index does not exist - */ - function indexInfo( $table, $index, $fname = __METHOD__ ) { - // TODO: Implement indexInfo() method. - } - - /** - * Get the number of rows affected by the last write query - * @see http://www.php.net/mysql_affected_rows - * - * @return int - */ - function affectedRows() { - // TODO: Implement affectedRows() method. - } - - /** - * Wrapper for addslashes() - * - * @param string $s String to be slashed. - * @return string Slashed string. - */ - function strencode( $s ) { - // TODO: Implement strencode() method. - } - - /** - * Returns a wikitext link to the DB's website, e.g., - * return "[http://www.mysql.com/ MySQL]"; - * Should at least contain plain text, if for some reason - * your database has no website. - * - * @return string Wikitext of a link to the server software's web site - */ - function getSoftwareLink() { - // TODO: Implement getSoftwareLink() method. - } - - /** - * A string describing the current software version, like from - * mysql_get_server_info(). - * - * @return string Version information from the database server. - */ - function getServerVersion() { - // TODO: Implement getServerVersion() method. - } - - /** - * Closes underlying database connection - * @since 1.20 - * @return bool Whether connection was closed successfully - */ - protected function closeConnection() { - // TODO: Implement closeConnection() method. - } - - /** - * The DBMS-dependent part of query() - * - * @param string $sql SQL query. - * @return ResultWrapper|bool Result object to feed to fetchObject, - * fetchRow, ...; or false on failure - */ - protected function doQuery( $sql ) { - // TODO: Implement doQuery() method. - } -} - -class FakeDatabaseUpdater extends DatabaseUpdater { - function __construct( $db ) { - $this->db = $db; - self::$updateCounter = 0; - } - - /** - * Get an array of updates to perform on the database. Should return a - * multi-dimensional array. The main key is the MediaWiki version (1.12, - * 1.13...) with the values being arrays of updates, identical to how - * updaters.inc did it (for now) - * - * @return array - */ - protected function getCoreUpdateList() { - return []; - } - - public function canUseNewUpdatelog() { - return true; - } - - public function setAppliedUpdates( $version, $updates = [] ) { - parent::setAppliedUpdates( $version, $updates ); - } -} diff --git a/tests/phpunit/includes/interwiki/ClassicInterwikiLookupTest.php b/tests/phpunit/includes/interwiki/ClassicInterwikiLookupTest.php new file mode 100644 index 0000000000..db6d00295c --- /dev/null +++ b/tests/phpunit/includes/interwiki/ClassicInterwikiLookupTest.php @@ -0,0 +1,236 @@ +delete( 'interwiki', '*', __METHOD__ ); + $dbw->insert( 'interwiki', array_values( $iwrows ), __METHOD__ ); + $this->tablesUsed[] = 'interwiki'; + } + + public function testDatabaseStorage() { + // NOTE: database setup is expensive, so we only do + // it once and run all the tests in one go. + $dewiki = [ + 'iw_prefix' => 'de', + 'iw_url' => 'http://de.wikipedia.org/wiki/', + 'iw_api' => 'http://de.wikipedia.org/w/api.php', + 'iw_wikiid' => 'dewiki', + 'iw_local' => 1, + 'iw_trans' => 0 + ]; + + $zzwiki = [ + 'iw_prefix' => 'zz', + 'iw_url' => 'http://zzwiki.org/wiki/', + 'iw_api' => 'http://zzwiki.org/w/api.php', + 'iw_wikiid' => 'zzwiki', + 'iw_local' => 0, + 'iw_trans' => 0 + ]; + + $this->populateDB( [ $dewiki, $zzwiki ] ); + $lookup = new \MediaWiki\Interwiki\ClassicInterwikiLookup( + Language::factory( 'en' ), + WANObjectCache::newEmpty(), + 60*60, + false, + 3, + 'en' + ); + + $this->assertEquals( + [ $dewiki, $zzwiki ], + $lookup->getAllPrefixes(), + 'getAllPrefixes()' + ); + $this->assertEquals( + [ $dewiki ], + $lookup->getAllPrefixes( true ), + 'getAllPrefixes()' + ); + $this->assertEquals( + [ $zzwiki ], + $lookup->getAllPrefixes( false ), + 'getAllPrefixes()' + ); + + $this->assertTrue( $lookup->isValidInterwiki( 'de' ), 'known prefix is valid' ); + $this->assertFalse( $lookup->isValidInterwiki( 'xyz' ), 'unknown prefix is valid' ); + + $this->assertNull( $lookup->fetch( null ), 'no prefix' ); + $this->assertFalse( $lookup->fetch( 'xyz' ), 'unknown prefix' ); + + $interwiki = $lookup->fetch( 'de' ); + $this->assertInstanceOf( 'Interwiki', $interwiki ); + $this->assertSame( $interwiki, $lookup->fetch( 'de' ), 'in-process caching' ); + + $this->assertSame( 'http://de.wikipedia.org/wiki/', $interwiki->getURL(), 'getURL' ); + $this->assertSame( 'http://de.wikipedia.org/w/api.php', $interwiki->getAPI(), 'getAPI' ); + $this->assertSame( 'dewiki', $interwiki->getWikiID(), 'getWikiID' ); + $this->assertSame( true, $interwiki->isLocal(), 'isLocal' ); + $this->assertSame( false, $interwiki->isTranscludable(), 'isTranscludable' ); + + $lookup->invalidateCache( 'de' ); + $this->assertNotSame( $interwiki, $lookup->fetch( 'de' ), 'invalidate cache' ); + } + + /** + * @param string $thisSite + * @param string[] $local + * @param string[] $global + * + * @return string[] + */ + private function populateHash( $thisSite, $local, $global ) { + $hash = []; + $hash[ '__sites:' . wfWikiID() ] = $thisSite; + + $globals = []; + $locals = []; + + foreach ( $local as $row ) { + $prefix = $row['iw_prefix']; + $data = $row['iw_local'] . ' ' . $row['iw_url']; + $locals[] = $prefix; + $hash[ "_{$thisSite}:{$prefix}" ] = $data; + } + + foreach ( $global as $row ) { + $prefix = $row['iw_prefix']; + $data = $row['iw_local'] . ' ' . $row['iw_url']; + $globals[] = $prefix; + $hash[ "__global:{$prefix}" ] = $data; + } + + $hash[ '__list:__global' ] = implode( ' ', $globals ); + $hash[ '__list:_' . $thisSite ] = implode( ' ', $locals ); + + return $hash; + } + + private function populateCDB( $thisSite, $local, $global ) { + $cdbFile = tempnam( wfTempDir(), 'MW-ClassicInterwikiLookupTest-' ) . '.cdb'; + $cdb = \Cdb\Writer::open( $cdbFile ); + + $hash = $this->populateHash( $thisSite, $local, $global ); + + foreach ( $hash as $key => $value ) { + $cdb->set( $key, $value ); + } + + $cdb->close(); + return $cdbFile; + } + + public function testCDBStorage() { + // NOTE: CDB setup is expensive, so we only do + // it once and run all the tests in one go. + + $dewiki = [ + 'iw_prefix' => 'de', + 'iw_url' => 'http://de.wikipedia.org/wiki/', + 'iw_local' => 1 + ]; + + $zzwiki = [ + 'iw_prefix' => 'zz', + 'iw_url' => 'http://zzwiki.org/wiki/', + 'iw_local' => 0 + ]; + + $cdbFile = $this->populateCDB( + 'en', + [ $dewiki ], + [ $zzwiki ] + ); + $lookup = new \MediaWiki\Interwiki\ClassicInterwikiLookup( + Language::factory( 'en' ), + WANObjectCache::newEmpty(), + 60*60, + $cdbFile, + 3, + 'en' + ); + + $this->assertEquals( + [ $dewiki, $zzwiki ], + $lookup->getAllPrefixes(), + 'getAllPrefixes()' + ); + + $this->assertTrue( $lookup->isValidInterwiki( 'de' ), 'known prefix is valid' ); + $this->assertTrue( $lookup->isValidInterwiki( 'zz' ), 'known prefix is valid' ); + + $interwiki = $lookup->fetch( 'de' ); + $this->assertInstanceOf( 'Interwiki', $interwiki ); + + $this->assertSame( 'http://de.wikipedia.org/wiki/', $interwiki->getURL(), 'getURL' ); + $this->assertSame( true, $interwiki->isLocal(), 'isLocal' ); + + $interwiki = $lookup->fetch( 'zz' ); + $this->assertInstanceOf( 'Interwiki', $interwiki ); + + $this->assertSame( 'http://zzwiki.org/wiki/', $interwiki->getURL(), 'getURL' ); + $this->assertSame( false, $interwiki->isLocal(), 'isLocal' ); + + // cleanup temp file + unlink( $cdbFile ); + } + + public function testArrayStorage() { + $dewiki = [ + 'iw_prefix' => 'de', + 'iw_url' => 'http://de.wikipedia.org/wiki/', + 'iw_local' => 1 + ]; + + $zzwiki = [ + 'iw_prefix' => 'zz', + 'iw_url' => 'http://zzwiki.org/wiki/', + 'iw_local' => 0 + ]; + + $hash = $this->populateHash( + 'en', + [ $dewiki ], + [ $zzwiki ] + ); + $lookup = new \MediaWiki\Interwiki\ClassicInterwikiLookup( + Language::factory( 'en' ), + WANObjectCache::newEmpty(), + 60*60, + $hash, + 3, + 'en' + ); + + $this->assertEquals( + [ $dewiki, $zzwiki ], + $lookup->getAllPrefixes(), + 'getAllPrefixes()' + ); + + $this->assertTrue( $lookup->isValidInterwiki( 'de' ), 'known prefix is valid' ); + $this->assertTrue( $lookup->isValidInterwiki( 'zz' ), 'known prefix is valid' ); + + $interwiki = $lookup->fetch( 'de' ); + $this->assertInstanceOf( 'Interwiki', $interwiki ); + + $this->assertSame( 'http://de.wikipedia.org/wiki/', $interwiki->getURL(), 'getURL' ); + $this->assertSame( true, $interwiki->isLocal(), 'isLocal' ); + + $interwiki = $lookup->fetch( 'zz' ); + $this->assertInstanceOf( 'Interwiki', $interwiki ); + + $this->assertSame( 'http://zzwiki.org/wiki/', $interwiki->getURL(), 'getURL' ); + $this->assertSame( false, $interwiki->isLocal(), 'isLocal' ); + } + +} diff --git a/tests/phpunit/includes/interwiki/InterwikiLookupAdapterTest.php b/tests/phpunit/includes/interwiki/InterwikiLookupAdapterTest.php new file mode 100644 index 0000000000..4754b040f9 --- /dev/null +++ b/tests/phpunit/includes/interwiki/InterwikiLookupAdapterTest.php @@ -0,0 +1,117 @@ +interwikiLookup = new InterwikiLookupAdapter( + $this->getSiteLookup( $this->getSites() ) + ); + } + + public function testIsValidInterwiki() { + $this->assertTrue( + $this->interwikiLookup->isValidInterwiki( 'enwt' ), + 'enwt known prefix is valid' + ); + $this->assertTrue( + $this->interwikiLookup->isValidInterwiki( 'foo' ), + 'foo site known prefix is valid' + ); + $this->assertFalse( + $this->interwikiLookup->isValidInterwiki( 'xyz' ), + 'unknown prefix is not valid' + ); + } + + public function testFetch() { + + $interwiki = $this->interwikiLookup->fetch( '' ); + $this->assertNull( $interwiki ); + + $interwiki = $this->interwikiLookup->fetch( 'xyz' ); + $this->assertFalse( $interwiki ); + + $interwiki = $this->interwikiLookup->fetch( 'foo' ); + $this->assertInstanceOf( Interwiki::class, $interwiki ); + $this->assertSame( 'foobar', $interwiki->getWikiID() ); + + $interwiki = $this->interwikiLookup->fetch( 'enwt' ); + $this->assertInstanceOf( Interwiki::class, $interwiki ); + + $this->assertSame( 'https://en.wiktionary.org/wiki/$1', $interwiki->getURL(), 'getURL' ); + $this->assertSame( 'https://en.wiktionary.org/w/api.php', $interwiki->getAPI(), 'getAPI' ); + $this->assertSame( 'enwiktionary', $interwiki->getWikiID(), 'getWikiID' ); + $this->assertTrue( $interwiki->isLocal(), 'isLocal' ); + } + + public function testGetAllPrefixes() { + $this->assertEquals( + [ 'foo', 'enwt' ], + $this->interwikiLookup->getAllPrefixes(), + 'getAllPrefixes()' + ); + + $this->assertEquals( + [ 'foo' ], + $this->interwikiLookup->getAllPrefixes( false ), + 'get external prefixes' + ); + + $this->assertEquals( + [ 'enwt' ], + $this->interwikiLookup->getAllPrefixes( true ), + 'get local prefixes' + ); + } + + private function getSiteLookup( SiteList $sites ) { + $siteLookup = $this->getMockBuilder( SiteLookup::class ) + ->disableOriginalConstructor() + ->getMock(); + + $siteLookup->expects( $this->any() ) + ->method( 'getSites' ) + ->will( $this->returnValue( $sites ) ); + + return $siteLookup; + } + + private function getSites() { + $sites = []; + + $site = new Site(); + $site->setGlobalId( 'foobar' ); + $site->addInterwikiId( 'foo' ); + $site->setSource( 'external' ); + $sites[] = $site; + + $site = new MediaWikiSite(); + $site->setGlobalId( 'enwiktionary' ); + $site->setGroup( 'wiktionary' ); + $site->setLanguageCode( 'en' ); + $site->addNavigationId( 'enwiktionary' ); + $site->addInterwikiId( 'enwt' ); + $site->setSource( 'local' ); + $site->setPath( MediaWikiSite::PATH_PAGE, "https://en.wiktionary.org/wiki/$1" ); + $site->setPath( MediaWikiSite::PATH_FILE, "https://en.wiktionary.org/w/$1" ); + $sites[] = $site; + + return new SiteList( $sites ); + } + +} diff --git a/tests/phpunit/includes/interwiki/InterwikiTest.php b/tests/phpunit/includes/interwiki/InterwikiTest.php index 411d6a3ff6..137dfb77ec 100644 --- a/tests/phpunit/includes/interwiki/InterwikiTest.php +++ b/tests/phpunit/includes/interwiki/InterwikiTest.php @@ -1,4 +1,6 @@ tablesUsed[] = 'interwiki'; } + private function setWgInterwikiCache( $interwikiCache ) { + $this->overrideMwServices(); + MediaWikiServices::getInstance()->resetServiceForTesting( 'InterwikiLookup' ); + $this->setMwGlobals( 'wgInterwikiCache', $interwikiCache ); + } + public function testDatabaseStorage() { + $this->markTestSkipped( 'Needs I37b8e8018b3 ' ); + // NOTE: database setup is expensive, so we only do // it once and run all the tests in one go. $dewiki = [ @@ -70,8 +80,7 @@ class InterwikiTest extends MediaWikiTestCase { $this->populateDB( [ $dewiki, $zzwiki ] ); - Interwiki::resetLocalCache(); - $this->setMwGlobals( 'wgInterwikiCache', false ); + $this->setWgInterwikiCache( false ); $this->assertEquals( [ $dewiki, $zzwiki ], @@ -179,8 +188,7 @@ class InterwikiTest extends MediaWikiTestCase { [ $zzwiki ] ); - Interwiki::resetLocalCache(); - $this->setMwGlobals( 'wgInterwikiCache', $cdbFile ); + $this->setWgInterwikiCache( $cdbFile ); $this->assertEquals( [ $dewiki, $zzwiki ], @@ -226,8 +234,7 @@ class InterwikiTest extends MediaWikiTestCase { [ $zzwiki ] ); - Interwiki::resetLocalCache(); - $this->setMwGlobals( 'wgInterwikiCache', $cdbData ); + $this->setWgInterwikiCache( $cdbData ); $this->assertEquals( [ $dewiki, $zzwiki ], diff --git a/tests/phpunit/includes/json/FormatJsonTest.php b/tests/phpunit/includes/json/FormatJsonTest.php index 01b575ca93..d252c80732 100644 --- a/tests/phpunit/includes/json/FormatJsonTest.php +++ b/tests/phpunit/includes/json/FormatJsonTest.php @@ -146,7 +146,7 @@ class FormatJsonTest extends MediaWikiTestCase { * @return stdClass|string|bool|int|float|null */ public static function toObject( $value ) { - return !is_array( $value ) ? $value : (object) array_map( __METHOD__, $value ); + return !is_array( $value ) ? $value : (object)array_map( __METHOD__, $value ); } /** diff --git a/tests/phpunit/includes/libs/CSSMinTest.php b/tests/phpunit/includes/libs/CSSMinTest.php index 5f5a1e8f01..366714b193 100644 --- a/tests/phpunit/includes/libs/CSSMinTest.php +++ b/tests/phpunit/includes/libs/CSSMinTest.php @@ -129,8 +129,8 @@ class CSSMinTest extends MediaWikiTestCase { * @covers CSSMin::remap */ public function testRemapRemapping( $message, $input, $expectedOutput ) { - $localPath = __DIR__ . '/../../data/cssmin/'; - $remotePath = 'http://localhost/w/'; + $localPath = __DIR__ . '/../../data/cssmin'; + $remotePath = 'http://localhost/w'; $realOutput = CSSMin::remap( $input, $localPath, $remotePath ); $this->assertEquals( $expectedOutput, $realOutput, "CSSMin::remap: $message" ); diff --git a/tests/phpunit/includes/libs/HtmlArmorTest.php b/tests/phpunit/includes/libs/HtmlArmorTest.php new file mode 100644 index 0000000000..5f176e0c85 --- /dev/null +++ b/tests/phpunit/includes/libs/HtmlArmorTest.php @@ -0,0 +1,34 @@ +alert("evil!");', + '<script>alert("evil!");</script>', + ], + [ + new HtmlArmor( '' ), + '', + ], + ]; + } + + /** + * @dataProvider provideHtmlArmor + */ + public function testHtmlArmor( $input, $expected ) { + $this->assertEquals( + $expected, + HtmlArmor::getHtml( $input ) + ); + } +} diff --git a/tests/phpunit/includes/libs/IPTest.php b/tests/phpunit/includes/libs/IPTest.php new file mode 100644 index 0000000000..307652d938 --- /dev/null +++ b/tests/phpunit/includes/libs/IPTest.php @@ -0,0 +1,670 @@ +assertFalse( IP::isIPAddress( $val ), $desc ); + } + + /** + * Provide a list of things that aren't IP addresses + */ + public function provideInvalidIPs() { + return [ + [ false, 'Boolean false is not an IP' ], + [ true, 'Boolean true is not an IP' ], + [ '', 'Empty string is not an IP' ], + [ 'abc', 'Garbage IP string' ], + [ ':', 'Single ":" is not an IP' ], + [ '2001:0DB8::A:1::1', 'IPv6 with a double :: occurrence' ], + [ '2001:0DB8::A:1::', 'IPv6 with a double :: occurrence, last at end' ], + [ '::2001:0DB8::5:1', 'IPv6 with a double :: occurrence, firt at beginning' ], + [ '124.24.52', 'IPv4 not enough quads' ], + [ '24.324.52.13', 'IPv4 out of range' ], + [ '.24.52.13', 'IPv4 starts with period' ], + [ 'fc:100:300', 'IPv6 with only 3 words' ], + ]; + } + + /** + * @covers IP::isIPAddress + */ + public function testisIPAddress() { + $this->assertTrue( IP::isIPAddress( '::' ), 'RFC 4291 IPv6 Unspecified Address' ); + $this->assertTrue( IP::isIPAddress( '::1' ), 'RFC 4291 IPv6 Loopback Address' ); + $this->assertTrue( IP::isIPAddress( '74.24.52.13/20' ), 'IPv4 range' ); + $this->assertTrue( IP::isIPAddress( 'fc:100:a:d:1:e:ac:0/24' ), 'IPv6 range' ); + $this->assertTrue( IP::isIPAddress( 'fc::100:a:d:1:e:ac/96' ), 'IPv6 range with "::"' ); + + $validIPs = [ 'fc:100::', 'fc:100:a:d:1:e:ac::', 'fc::100', '::fc:100:a:d:1:e:ac', + '::fc', 'fc::100:a:d:1:e:ac', 'fc:100:a:d:1:e:ac:0', '124.24.52.13', '1.24.52.13' ]; + foreach ( $validIPs as $ip ) { + $this->assertTrue( IP::isIPAddress( $ip ), "$ip is a valid IP address" ); + } + } + + /** + * @covers IP::isIPv6 + */ + public function testisIPv6() { + $this->assertFalse( IP::isIPv6( ':fc:100::' ), 'IPv6 starting with lone ":"' ); + $this->assertFalse( IP::isIPv6( 'fc:100:::' ), 'IPv6 ending with a ":::"' ); + $this->assertFalse( IP::isIPv6( 'fc:300' ), 'IPv6 with only 2 words' ); + $this->assertFalse( IP::isIPv6( 'fc:100:300' ), 'IPv6 with only 3 words' ); + + $this->assertTrue( IP::isIPv6( 'fc:100::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a:d::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1:e::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1:e:ac::' ) ); + + $this->assertFalse( IP::isIPv6( 'fc:100:a:d:1:e:ac:0::' ), 'IPv6 with 8 words ending with "::"' ); + $this->assertFalse( + IP::isIPv6( 'fc:100:a:d:1:e:ac:0:1::' ), + 'IPv6 with 9 words ending with "::"' + ); + + $this->assertFalse( IP::isIPv6( ':::' ) ); + $this->assertFalse( IP::isIPv6( '::0:' ), 'IPv6 ending in a lone ":"' ); + + $this->assertTrue( IP::isIPv6( '::' ), 'IPv6 zero address' ); + $this->assertTrue( IP::isIPv6( '::0' ) ); + $this->assertTrue( IP::isIPv6( '::fc' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a:d' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a:d:1' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a:d:1:e' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a:d:1:e:ac' ) ); + + $this->assertFalse( IP::isIPv6( '::fc:100:a:d:1:e:ac:0' ), 'IPv6 with "::" and 8 words' ); + $this->assertFalse( IP::isIPv6( '::fc:100:a:d:1:e:ac:0:1' ), 'IPv6 with 9 words' ); + + $this->assertFalse( IP::isIPv6( ':fc::100' ), 'IPv6 starting with lone ":"' ); + $this->assertFalse( IP::isIPv6( 'fc::100:' ), 'IPv6 ending with lone ":"' ); + $this->assertFalse( IP::isIPv6( 'fc:::100' ), 'IPv6 with ":::" in the middle' ); + + $this->assertTrue( IP::isIPv6( 'fc::100' ), 'IPv6 with "::" and 2 words' ); + $this->assertTrue( IP::isIPv6( 'fc::100:a' ), 'IPv6 with "::" and 3 words' ); + $this->assertTrue( IP::isIPv6( 'fc::100:a:d' ), 'IPv6 with "::" and 4 words' ); + $this->assertTrue( IP::isIPv6( 'fc::100:a:d:1' ), 'IPv6 with "::" and 5 words' ); + $this->assertTrue( IP::isIPv6( 'fc::100:a:d:1:e' ), 'IPv6 with "::" and 6 words' ); + $this->assertTrue( IP::isIPv6( 'fc::100:a:d:1:e:ac' ), 'IPv6 with "::" and 7 words' ); + $this->assertTrue( IP::isIPv6( '2001::df' ), 'IPv6 with "::" and 2 words' ); + $this->assertTrue( IP::isIPv6( '2001:5c0:1400:a::df' ), 'IPv6 with "::" and 5 words' ); + $this->assertTrue( IP::isIPv6( '2001:5c0:1400:a::df:2' ), 'IPv6 with "::" and 6 words' ); + + $this->assertFalse( IP::isIPv6( 'fc::100:a:d:1:e:ac:0' ), 'IPv6 with "::" and 8 words' ); + $this->assertFalse( IP::isIPv6( 'fc::100:a:d:1:e:ac:0:1' ), 'IPv6 with 9 words' ); + + $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1:e:ac:0' ) ); + } + + /** + * @covers IP::isIPv4 + * @dataProvider provideInvalidIPv4Addresses + */ + public function testisNotIPv4( $bogusIP, $desc ) { + $this->assertFalse( IP::isIPv4( $bogusIP ), $desc ); + } + + public function provideInvalidIPv4Addresses() { + return [ + [ false, 'Boolean false is not an IP' ], + [ true, 'Boolean true is not an IP' ], + [ '', 'Empty string is not an IP' ], + [ 'abc', 'Letters are not an IP' ], + [ ':', 'A colon is not an IP' ], + [ '124.24.52', 'IPv4 not enough quads' ], + [ '24.324.52.13', 'IPv4 out of range' ], + [ '.24.52.13', 'IPv4 starts with period' ], + ]; + } + + /** + * @covers IP::isIPv4 + * @dataProvider provideValidIPv4Address + */ + public function testIsIPv4( $ip, $desc ) { + $this->assertTrue( IP::isIPv4( $ip ), $desc ); + } + + /** + * Provide some IPv4 addresses and ranges + */ + public function provideValidIPv4Address() { + return [ + [ '124.24.52.13', 'Valid IPv4 address' ], + [ '1.24.52.13', 'Another valid IPv4 address' ], + [ '74.24.52.13/20', 'An IPv4 range' ], + ]; + } + + /** + * @covers IP::isValid + */ + public function testValidIPs() { + foreach ( range( 0, 255 ) as $i ) { + $a = sprintf( "%03d", $i ); + $b = sprintf( "%02d", $i ); + $c = sprintf( "%01d", $i ); + foreach ( array_unique( [ $a, $b, $c ] ) as $f ) { + $ip = "$f.$f.$f.$f"; + $this->assertTrue( IP::isValid( $ip ), "$ip is a valid IPv4 address" ); + } + } + foreach ( range( 0x0, 0xFFFF, 0xF ) as $i ) { + $a = sprintf( "%04x", $i ); + $b = sprintf( "%03x", $i ); + $c = sprintf( "%02x", $i ); + foreach ( array_unique( [ $a, $b, $c ] ) as $f ) { + $ip = "$f:$f:$f:$f:$f:$f:$f:$f"; + $this->assertTrue( IP::isValid( $ip ), "$ip is a valid IPv6 address" ); + } + } + // test with some abbreviations + $this->assertFalse( IP::isValid( ':fc:100::' ), 'IPv6 starting with lone ":"' ); + $this->assertFalse( IP::isValid( 'fc:100:::' ), 'IPv6 ending with a ":::"' ); + $this->assertFalse( IP::isValid( 'fc:300' ), 'IPv6 with only 2 words' ); + $this->assertFalse( IP::isValid( 'fc:100:300' ), 'IPv6 with only 3 words' ); + + $this->assertTrue( IP::isValid( 'fc:100::' ) ); + $this->assertTrue( IP::isValid( 'fc:100:a:d:1:e::' ) ); + $this->assertTrue( IP::isValid( 'fc:100:a:d:1:e:ac::' ) ); + + $this->assertTrue( IP::isValid( 'fc::100' ), 'IPv6 with "::" and 2 words' ); + $this->assertTrue( IP::isValid( 'fc::100:a' ), 'IPv6 with "::" and 3 words' ); + $this->assertTrue( IP::isValid( '2001::df' ), 'IPv6 with "::" and 2 words' ); + $this->assertTrue( IP::isValid( '2001:5c0:1400:a::df' ), 'IPv6 with "::" and 5 words' ); + $this->assertTrue( IP::isValid( '2001:5c0:1400:a::df:2' ), 'IPv6 with "::" and 6 words' ); + $this->assertTrue( IP::isValid( 'fc::100:a:d:1' ), 'IPv6 with "::" and 5 words' ); + $this->assertTrue( IP::isValid( 'fc::100:a:d:1:e:ac' ), 'IPv6 with "::" and 7 words' ); + + $this->assertFalse( + IP::isValid( 'fc:100:a:d:1:e:ac:0::' ), + 'IPv6 with 8 words ending with "::"' + ); + $this->assertFalse( + IP::isValid( 'fc:100:a:d:1:e:ac:0:1::' ), + 'IPv6 with 9 words ending with "::"' + ); + } + + /** + * @covers IP::isValid + */ + public function testInvalidIPs() { + // Out of range... + foreach ( range( 256, 999 ) as $i ) { + $a = sprintf( "%03d", $i ); + $b = sprintf( "%02d", $i ); + $c = sprintf( "%01d", $i ); + foreach ( array_unique( [ $a, $b, $c ] ) as $f ) { + $ip = "$f.$f.$f.$f"; + $this->assertFalse( IP::isValid( $ip ), "$ip is not a valid IPv4 address" ); + } + } + foreach ( range( 'g', 'z' ) as $i ) { + $a = sprintf( "%04s", $i ); + $b = sprintf( "%03s", $i ); + $c = sprintf( "%02s", $i ); + foreach ( array_unique( [ $a, $b, $c ] ) as $f ) { + $ip = "$f:$f:$f:$f:$f:$f:$f:$f"; + $this->assertFalse( IP::isValid( $ip ), "$ip is not a valid IPv6 address" ); + } + } + // Have CIDR + $ipCIDRs = [ + '212.35.31.121/32', + '212.35.31.121/18', + '212.35.31.121/24', + '::ff:d:321:5/96', + 'ff::d3:321:5/116', + 'c:ff:12:1:ea:d:321:5/120', + ]; + foreach ( $ipCIDRs as $i ) { + $this->assertFalse( IP::isValid( $i ), + "$i is an invalid IP address because it is a block" ); + } + // Incomplete/garbage + $invalid = [ + 'www.xn--var-xla.net', + '216.17.184.G', + '216.17.184.1.', + '216.17.184', + '216.17.184.', + '256.17.184.1' + ]; + foreach ( $invalid as $i ) { + $this->assertFalse( IP::isValid( $i ), "$i is an invalid IP address" ); + } + } + + /** + * Provide some valid IP blocks + */ + public function provideValidBlocks() { + return [ + [ '116.17.184.5/32' ], + [ '0.17.184.5/30' ], + [ '16.17.184.1/24' ], + [ '30.242.52.14/1' ], + [ '10.232.52.13/8' ], + [ '30.242.52.14/0' ], + [ '::e:f:2001/96' ], + [ '::c:f:2001/128' ], + [ '::10:f:2001/70' ], + [ '::fe:f:2001/1' ], + [ '::6d:f:2001/8' ], + [ '::fe:f:2001/0' ], + ]; + } + + /** + * @covers IP::isValidBlock + * @dataProvider provideValidBlocks + */ + public function testValidBlocks( $block ) { + $this->assertTrue( IP::isValidBlock( $block ), "$block is a valid IP block" ); + } + + /** + * @covers IP::isValidBlock + * @dataProvider provideInvalidBlocks + */ + public function testInvalidBlocks( $invalid ) { + $this->assertFalse( IP::isValidBlock( $invalid ), "$invalid is not a valid IP block" ); + } + + public function provideInvalidBlocks() { + return [ + [ '116.17.184.5/33' ], + [ '0.17.184.5/130' ], + [ '16.17.184.1/-1' ], + [ '10.232.52.13/*' ], + [ '7.232.52.13/ab' ], + [ '11.232.52.13/' ], + [ '::e:f:2001/129' ], + [ '::c:f:2001/228' ], + [ '::10:f:2001/-1' ], + [ '::6d:f:2001/*' ], + [ '::86:f:2001/ab' ], + [ '::23:f:2001/' ], + ]; + } + + /** + * @covers IP::sanitizeIP + * @dataProvider provideSanitizeIP + */ + public function testSanitizeIP( $expected, $input ) { + $result = IP::sanitizeIP( $input ); + $this->assertEquals( $expected, $result ); + } + + /** + * Provider for IP::testSanitizeIP() + */ + public static function provideSanitizeIP() { + return [ + [ '0.0.0.0', '0.0.0.0' ], + [ '0.0.0.0', '00.00.00.00' ], + [ '0.0.0.0', '000.000.000.000' ], + [ '141.0.11.253', '141.000.011.253' ], + [ '1.2.4.5', '1.2.4.5' ], + [ '1.2.4.5', '01.02.04.05' ], + [ '1.2.4.5', '001.002.004.005' ], + [ '10.0.0.1', '010.0.000.1' ], + [ '80.72.250.4', '080.072.250.04' ], + [ 'Foo.1000.00', 'Foo.1000.00' ], + [ 'Bar.01', 'Bar.01' ], + [ 'Bar.010', 'Bar.010' ], + [ null, '' ], + [ null, ' ' ] + ]; + } + + /** + * @covers IP::toHex + * @dataProvider provideToHex + */ + public function testToHex( $expected, $input ) { + $result = IP::toHex( $input ); + $this->assertTrue( $result === false || is_string( $result ) ); + $this->assertEquals( $expected, $result ); + } + + /** + * Provider for IP::testToHex() + */ + public static function provideToHex() { + return [ + [ '00000001', '0.0.0.1' ], + [ '01020304', '1.2.3.4' ], + [ '7F000001', '127.0.0.1' ], + [ '80000000', '128.0.0.0' ], + [ 'DEADCAFE', '222.173.202.254' ], + [ 'FFFFFFFF', '255.255.255.255' ], + [ '8D000BFD', '141.000.11.253' ], + [ false, 'IN.VA.LI.D' ], + [ 'v6-00000000000000000000000000000001', '::1' ], + [ 'v6-20010DB885A3000000008A2E03707334', '2001:0db8:85a3:0000:0000:8a2e:0370:7334' ], + [ 'v6-20010DB885A3000000008A2E03707334', '2001:db8:85a3::8a2e:0370:7334' ], + [ false, 'IN:VA::LI:D' ], + [ false, ':::1' ] + ]; + } + + /** + * @covers IP::isPublic + * @dataProvider provideIsPublic + */ + public function testIsPublic( $expected, $input ) { + $result = IP::isPublic( $input ); + $this->assertEquals( $expected, $result ); + } + + /** + * Provider for IP::testIsPublic() + */ + public static function provideIsPublic() { + return [ + [ false, 'fc00::3' ], # RFC 4193 (local) + [ false, 'fc00::ff' ], # RFC 4193 (local) + [ false, '127.1.2.3' ], # loopback + [ false, '::1' ], # loopback + [ false, 'fe80::1' ], # link-local + [ false, '169.254.1.1' ], # link-local + [ false, '10.0.0.1' ], # RFC 1918 (private) + [ false, '172.16.0.1' ], # RFC 1918 (private) + [ false, '192.168.0.1' ], # RFC 1918 (private) + [ true, '2001:5c0:1000:a::133' ], # public + [ true, 'fc::3' ], # public + [ true, '00FC::' ] # public + ]; + } + + // Private wrapper used to test CIDR Parsing. + private function assertFalseCIDR( $CIDR, $msg = '' ) { + $ff = [ false, false ]; + $this->assertEquals( $ff, IP::parseCIDR( $CIDR ), $msg ); + } + + // Private wrapper to test network shifting using only dot notation + private function assertNet( $expected, $CIDR ) { + $parse = IP::parseCIDR( $CIDR ); + $this->assertEquals( $expected, long2ip( $parse[0] ), "network shifting $CIDR" ); + } + + /** + * @covers IP::hexToQuad + * @dataProvider provideIPsAndHexes + */ + public function testHexToQuad( $ip, $hex ) { + $this->assertEquals( $ip, IP::hexToQuad( $hex ) ); + } + + /** + * Provide some IP addresses and their equivalent hex representations + */ + public function provideIPsandHexes() { + return [ + [ '0.0.0.1', '00000001' ], + [ '255.0.0.0', 'FF000000' ], + [ '255.255.255.255', 'FFFFFFFF' ], + [ '10.188.222.255', '0ABCDEFF' ], + // hex not left-padded... + [ '0.0.0.0', '0' ], + [ '0.0.0.1', '1' ], + [ '0.0.0.255', 'FF' ], + [ '0.0.255.0', 'FF00' ], + ]; + } + + /** + * @covers IP::hexToOctet + * @dataProvider provideOctetsAndHexes + */ + public function testHexToOctet( $octet, $hex ) { + $this->assertEquals( $octet, IP::hexToOctet( $hex ) ); + } + + /** + * Provide some hex and octet representations of the same IPs + */ + public function provideOctetsAndHexes() { + return [ + [ '0:0:0:0:0:0:0:1', '00000000000000000000000000000001' ], + [ '0:0:0:0:0:0:FF:3', '00000000000000000000000000FF0003' ], + [ '0:0:0:0:0:0:FF00:6', '000000000000000000000000FF000006' ], + [ '0:0:0:0:0:0:FCCF:FAFF', '000000000000000000000000FCCFFAFF' ], + [ 'FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF', 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' ], + // hex not left-padded... + [ '0:0:0:0:0:0:0:0', '0' ], + [ '0:0:0:0:0:0:0:1', '1' ], + [ '0:0:0:0:0:0:0:FF', 'FF' ], + [ '0:0:0:0:0:0:0:FFD0', 'FFD0' ], + [ '0:0:0:0:0:0:FA00:0', 'FA000000' ], + [ '0:0:0:0:0:0:FCCF:FAFF', 'FCCFFAFF' ], + ]; + } + + /** + * IP::parseCIDR() returns an array containing a signed IP address + * representing the network mask and the bit mask. + * @covers IP::parseCIDR + */ + public function testCIDRParsing() { + $this->assertFalseCIDR( '192.0.2.0', "missing mask" ); + $this->assertFalseCIDR( '192.0.2.0/', "missing bitmask" ); + + // Verify if statement + $this->assertFalseCIDR( '256.0.0.0/32', "invalid net" ); + $this->assertFalseCIDR( '192.0.2.0/AA', "mask not numeric" ); + $this->assertFalseCIDR( '192.0.2.0/-1', "mask < 0" ); + $this->assertFalseCIDR( '192.0.2.0/33', "mask > 32" ); + + // Check internal logic + # 0 mask always result in array(0,0) + $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '192.0.0.2/0' ) ); + $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '0.0.0.0/0' ) ); + $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '255.255.255.255/0' ) ); + + // @todo FIXME: Add more tests. + + # This part test network shifting + $this->assertNet( '192.0.0.0', '192.0.0.2/24' ); + $this->assertNet( '192.168.5.0', '192.168.5.13/24' ); + $this->assertNet( '10.0.0.160', '10.0.0.161/28' ); + $this->assertNet( '10.0.0.0', '10.0.0.3/28' ); + $this->assertNet( '10.0.0.0', '10.0.0.3/30' ); + $this->assertNet( '10.0.0.4', '10.0.0.4/30' ); + $this->assertNet( '172.17.32.0', '172.17.35.48/21' ); + $this->assertNet( '10.128.0.0', '10.135.0.0/9' ); + $this->assertNet( '134.0.0.0', '134.0.5.1/8' ); + } + + /** + * @covers IP::canonicalize + */ + public function testIPCanonicalizeOnValidIp() { + $this->assertEquals( '192.0.2.152', IP::canonicalize( '192.0.2.152' ), + 'Canonicalization of a valid IP returns it unchanged' ); + } + + /** + * @covers IP::canonicalize + */ + public function testIPCanonicalizeMappedAddress() { + $this->assertEquals( + '192.0.2.152', + IP::canonicalize( '::ffff:192.0.2.152' ) + ); + $this->assertEquals( + '192.0.2.152', + IP::canonicalize( '::192.0.2.152' ) + ); + } + + /** + * Issues there are most probably from IP::toHex() or IP::parseRange() + * @covers IP::isInRange + * @dataProvider provideIPsAndRanges + */ + public function testIPIsInRange( $expected, $addr, $range, $message = '' ) { + $this->assertEquals( + $expected, + IP::isInRange( $addr, $range ), + $message + ); + } + + /** Provider for testIPIsInRange() */ + public static function provideIPsAndRanges() { + # Format: (expected boolean, address, range, optional message) + return [ + # IPv4 + [ true, '192.0.2.0', '192.0.2.0/24', 'Network address' ], + [ true, '192.0.2.77', '192.0.2.0/24', 'Simple address' ], + [ true, '192.0.2.255', '192.0.2.0/24', 'Broadcast address' ], + + [ false, '0.0.0.0', '192.0.2.0/24' ], + [ false, '255.255.255', '192.0.2.0/24' ], + + # IPv6 + [ false, '::1', '2001:DB8::/32' ], + [ false, '::', '2001:DB8::/32' ], + [ false, 'FE80::1', '2001:DB8::/32' ], + + [ true, '2001:DB8::', '2001:DB8::/32' ], + [ true, '2001:0DB8::', '2001:DB8::/32' ], + [ true, '2001:DB8::1', '2001:DB8::/32' ], + [ true, '2001:0DB8::1', '2001:DB8::/32' ], + [ true, '2001:0DB8:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF', + '2001:DB8::/32' ], + + [ false, '2001:0DB8:F::', '2001:DB8::/96' ], + ]; + } + + /** + * Test for IP::splitHostAndPort(). + * @dataProvider provideSplitHostAndPort + */ + public function testSplitHostAndPort( $expected, $input, $description ) { + $this->assertEquals( $expected, IP::splitHostAndPort( $input ), $description ); + } + + /** + * Provider for IP::splitHostAndPort() + */ + public static function provideSplitHostAndPort() { + return [ + [ false, '[', 'Unclosed square bracket' ], + [ false, '[::', 'Unclosed square bracket 2' ], + [ [ '::', false ], '::', 'Bare IPv6 0' ], + [ [ '::1', false ], '::1', 'Bare IPv6 1' ], + [ [ '::', false ], '[::]', 'Bracketed IPv6 0' ], + [ [ '::1', false ], '[::1]', 'Bracketed IPv6 1' ], + [ [ '::1', 80 ], '[::1]:80', 'Bracketed IPv6 with port' ], + [ false, '::x', 'Double colon but no IPv6' ], + [ [ 'x', 80 ], 'x:80', 'Hostname and port' ], + [ false, 'x:x', 'Hostname and invalid port' ], + [ [ 'x', false ], 'x', 'Plain hostname' ] + ]; + } + + /** + * Test for IP::combineHostAndPort() + * @dataProvider provideCombineHostAndPort + */ + public function testCombineHostAndPort( $expected, $input, $description ) { + list( $host, $port, $defaultPort ) = $input; + $this->assertEquals( + $expected, + IP::combineHostAndPort( $host, $port, $defaultPort ), + $description ); + } + + /** + * Provider for IP::combineHostAndPort() + */ + public static function provideCombineHostAndPort() { + return [ + [ '[::1]', [ '::1', 2, 2 ], 'IPv6 default port' ], + [ '[::1]:2', [ '::1', 2, 3 ], 'IPv6 non-default port' ], + [ 'x', [ 'x', 2, 2 ], 'Normal default port' ], + [ 'x:2', [ 'x', 2, 3 ], 'Normal non-default port' ], + ]; + } + + /** + * Test for IP::sanitizeRange() + * @dataProvider provideIPCIDRs + */ + public function testSanitizeRange( $input, $expected, $description ) { + $this->assertEquals( $expected, IP::sanitizeRange( $input ), $description ); + } + + /** + * Provider for IP::testSanitizeRange() + */ + public static function provideIPCIDRs() { + return [ + [ '35.56.31.252/16', '35.56.0.0/16', 'IPv4 range' ], + [ '135.16.21.252/24', '135.16.21.0/24', 'IPv4 range' ], + [ '5.36.71.252/32', '5.36.71.252/32', 'IPv4 silly range' ], + [ '5.36.71.252', '5.36.71.252', 'IPv4 non-range' ], + [ '0:1:2:3:4:c5:f6:7/96', '0:1:2:3:4:C5:0:0/96', 'IPv6 range' ], + [ '0:1:2:3:4:5:6:7/120', '0:1:2:3:4:5:6:0/120', 'IPv6 range' ], + [ '0:e1:2:3:4:5:e6:7/128', '0:E1:2:3:4:5:E6:7/128', 'IPv6 silly range' ], + [ '0:c1:A2:3:4:5:c6:7', '0:C1:A2:3:4:5:C6:7', 'IPv6 non range' ], + ]; + } + + /** + * Test for IP::prettifyIP() + * @dataProvider provideIPsToPrettify + */ + public function testPrettifyIP( $ip, $prettified ) { + $this->assertEquals( $prettified, IP::prettifyIP( $ip ), "Prettify of $ip" ); + } + + /** + * Provider for IP::testPrettifyIP() + */ + public static function provideIPsToPrettify() { + return [ + [ '0:0:0:0:0:0:0:0', '::' ], + [ '0:0:0::0:0:0', '::' ], + [ '0:0:0:1:0:0:0:0', '0:0:0:1::' ], + [ '0:0::f', '::f' ], + [ '0::0:0:0:33:fef:b', '::33:fef:b' ], + [ '3f:535:0:0:0:0:e:fbb', '3f:535::e:fbb' ], + [ '0:0:fef:0:0:0:e:fbb', '0:0:fef::e:fbb' ], + [ 'abbc:2004::0:0:0:0', 'abbc:2004::' ], + [ 'cebc:2004:f:0:0:0:0:0', 'cebc:2004:f::' ], + [ '0:0:0:0:0:0:0:0/16', '::/16' ], + [ '0:0:0::0:0:0/64', '::/64' ], + [ '0:0::f/52', '::f/52' ], + [ '::0:0:33:fef:b/52', '::33:fef:b/52' ], + [ '3f:535:0:0:0:0:e:fbb/48', '3f:535::e:fbb/48' ], + [ '0:0:fef:0:0:0:e:fbb/96', '0:0:fef::e:fbb/96' ], + [ 'abbc:2004:0:0::0:0/40', 'abbc:2004::/40' ], + [ 'aebc:2004:f:0:0:0:0:0/80', 'aebc:2004:f::/80' ], + ]; + } +} diff --git a/tests/phpunit/includes/libs/MemoizedCallableTest.php b/tests/phpunit/includes/libs/MemoizedCallableTest.php index 6eb96b157e..881f5e1167 100644 --- a/tests/phpunit/includes/libs/MemoizedCallableTest.php +++ b/tests/phpunit/includes/libs/MemoizedCallableTest.php @@ -1,7 +1,7 @@ getMock( 'stdClass', [ 'computeSomething' ] ); diff --git a/tests/phpunit/includes/libs/ObjectFactoryTest.php b/tests/phpunit/includes/libs/ObjectFactoryTest.php index a4871a64c4..f8dda6f8af 100644 --- a/tests/phpunit/includes/libs/ObjectFactoryTest.php +++ b/tests/phpunit/includes/libs/ObjectFactoryTest.php @@ -44,6 +44,7 @@ class ObjectFactoryTest extends PHPUnit_Framework_TestCase { /** * @covers ObjectFactory::getObjectFromSpec + * @covers ObjectFactory::expandClosures */ public function testClosureExpansionEnabled() { $obj = ObjectFactory::getObjectFromSpec( [ @@ -80,6 +81,44 @@ class ObjectFactoryTest extends PHPUnit_Framework_TestCase { $this->assertSame( 'unwrapped', $obj->setterArgs[0] ); } + /** + * @covers ObjectFactory::getObjectFromSpec + */ + public function testGetObjectFromFactory() { + $args = [ 'a', 'b' ]; + $obj = ObjectFactory::getObjectFromSpec( [ + 'factory' => function ( $a, $b ) { + return new ObjectFactoryTestFixture( $a, $b ); + }, + 'args' => $args, + ] ); + $this->assertSame( $args, $obj->args ); + } + + /** + * @covers ObjectFactory::getObjectFromSpec + * @expectedException InvalidArgumentException + */ + public function testGetObjectFromInvalid() { + $args = [ 'a', 'b' ]; + $obj = ObjectFactory::getObjectFromSpec( [ + // Missing 'class' or 'factory' + 'args' => $args, + ] ); + } + + /** + * @covers ObjectFactory::getObjectFromSpec + * @dataProvider provideConstructClassInstance + */ + public function testGetObjectFromClass( $args ) { + $obj = ObjectFactory::getObjectFromSpec( [ + 'class' => 'ObjectFactoryTestFixture', + 'args' => $args, + ] ); + $this->assertSame( $args, $obj->args ); + } + /** * @covers ObjectFactory::constructClassInstance * @dataProvider provideConstructClassInstance @@ -91,7 +130,7 @@ class ObjectFactoryTest extends PHPUnit_Framework_TestCase { $this->assertSame( $args, $obj->args ); } - public function provideConstructClassInstance() { + public static function provideConstructClassInstance() { // These args go to 11. I thought about making 10 one louder, but 11! return [ '0 args' => [ [] ], @@ -110,6 +149,7 @@ class ObjectFactoryTest extends PHPUnit_Framework_TestCase { } /** + * @covers ObjectFactory::constructClassInstance * @expectedException InvalidArgumentException */ public function testNamedArgs() { diff --git a/tests/phpunit/includes/libs/SamplingStatsdClientTest.php b/tests/phpunit/includes/libs/SamplingStatsdClientTest.php index 1ebe55110f..9a489303a7 100644 --- a/tests/phpunit/includes/libs/SamplingStatsdClientTest.php +++ b/tests/phpunit/includes/libs/SamplingStatsdClientTest.php @@ -32,12 +32,35 @@ class SamplingStatsdClientTest extends PHPUnit_Framework_TestCase { return [ // $data, $sampleRate, $seed, $expectWrite - [ $unsampled, 1, 0 /*0.44*/, $unsampled ], - [ $sampled, 1, 0 /*0.44*/, null ], - [ $sampled, 1, 4 /*0.03*/, $sampled ], - [ $unsampled, 0.1, 4 /*0.03*/, $sampled ], - [ $sampled, 0.5, 0 /*0.44*/, null ], - [ $sampled, 0.5, 4 /*0.03*/, $sampled ], + [ $unsampled, 1, 0 /*0.44*/, true ], + [ $sampled, 1, 0 /*0.44*/, false ], + [ $sampled, 1, 4 /*0.03*/, true ], + [ $unsampled, 0.1, 0 /*0.44*/, false ], + [ $sampled, 0.5, 0 /*0.44*/, false ], + [ $sampled, 0.5, 4 /*0.03*/, false ], ]; } + + public function testSetSamplingRates() { + $matching = new StatsdData(); + $matching->setKey( 'foo.bar' ); + $matching->setValue( 1 ); + + $nonMatching = new StatsdData(); + $nonMatching->setKey( 'oof.bar' ); + $nonMatching->setValue( 1 ); + + $sender = $this->getMock( 'Liuggio\StatsdClient\Sender\SenderInterface' ); + $sender->expects( $this->any() )->method( 'open' )->will( $this->returnValue( true ) ); + $sender->expects( $this->once() )->method( 'write' )->with( $this->anything(), + $this->equalTo( $nonMatching ) ); + + $client = new SamplingStatsdClient( $sender ); + $client->setSamplingRates( [ 'foo.*' => 0.2 ] ); + + mt_srand( 0 ); // next random is 0.44 + $client->send( $matching ); + mt_srand( 0 ); + $client->send( $nonMatching ); + } } diff --git a/tests/phpunit/includes/libs/XhprofDataTest.php b/tests/phpunit/includes/libs/XhprofDataTest.php new file mode 100644 index 0000000000..a0fb563802 --- /dev/null +++ b/tests/phpunit/includes/libs/XhprofDataTest.php @@ -0,0 +1,277 @@ + + * @copyright © 2014 Bryan Davis and Wikimedia Foundation. + * @since 1.25 + */ +class XhprofDataTest extends PHPUnit_Framework_TestCase { + + /** + * @covers XhprofData::splitKey + * @dataProvider provideSplitKey + */ + public function testSplitKey( $key, $expect ) { + $this->assertSame( $expect, XhprofData::splitKey( $key ) ); + } + + public function provideSplitKey() { + return [ + [ 'main()', [ null, 'main()' ] ], + [ 'foo==>bar', [ 'foo', 'bar' ] ], + [ 'bar@1==>bar@2', [ 'bar@1', 'bar@2' ] ], + [ 'foo==>bar==>baz', [ 'foo', 'bar==>baz' ] ], + [ '==>bar', [ '', 'bar' ] ], + [ '', [ null, '' ] ], + ]; + } + + /** + * @covers XhprofData::pruneData + */ + public function testInclude() { + $xhprofData = $this->getXhprofDataFixture( [ + 'include' => [ 'main()' ], + ] ); + $raw = $xhprofData->getRawData(); + $this->assertArrayHasKey( 'main()', $raw ); + $this->assertArrayHasKey( 'main()==>foo', $raw ); + $this->assertArrayHasKey( 'main()==>xhprof_disable', $raw ); + $this->assertSame( 3, count( $raw ) ); + } + + /** + * Validate the structure of data returned by + * Xhprof::getInclusiveMetrics(). This acts as a guard against unexpected + * structural changes to the returned data in lieu of using a more heavy + * weight typed response object. + * + * @covers XhprofData::getInclusiveMetrics + */ + public function testInclusiveMetricsStructure() { + $metricStruct = [ + 'ct' => 'int', + 'wt' => 'array', + 'cpu' => 'array', + 'mu' => 'array', + 'pmu' => 'array', + ]; + $statStruct = [ + 'total' => 'numeric', + 'min' => 'numeric', + 'mean' => 'numeric', + 'max' => 'numeric', + 'variance' => 'numeric', + 'percent' => 'numeric', + ]; + + $xhprofData = $this->getXhprofDataFixture(); + $metrics = $xhprofData->getInclusiveMetrics(); + + foreach ( $metrics as $name => $metric ) { + $this->assertArrayStructure( $metricStruct, $metric ); + + foreach ( $metricStruct as $key => $type ) { + if ( $type === 'array' ) { + $this->assertArrayStructure( $statStruct, $metric[$key] ); + if ( $name === 'main()' ) { + $this->assertEquals( 100, $metric[$key]['percent'] ); + } + } + } + } + } + + /** + * Validate the structure of data returned by + * Xhprof::getCompleteMetrics(). This acts as a guard against unexpected + * structural changes to the returned data in lieu of using a more heavy + * weight typed response object. + * + * @covers XhprofData::getCompleteMetrics + */ + public function testCompleteMetricsStructure() { + $metricStruct = [ + 'ct' => 'int', + 'wt' => 'array', + 'cpu' => 'array', + 'mu' => 'array', + 'pmu' => 'array', + 'calls' => 'array', + 'subcalls' => 'array', + ]; + $statsMetrics = [ 'wt', 'cpu', 'mu', 'pmu' ]; + $statStruct = [ + 'total' => 'numeric', + 'min' => 'numeric', + 'mean' => 'numeric', + 'max' => 'numeric', + 'variance' => 'numeric', + 'percent' => 'numeric', + 'exclusive' => 'numeric', + ]; + + $xhprofData = $this->getXhprofDataFixture(); + $metrics = $xhprofData->getCompleteMetrics(); + + foreach ( $metrics as $name => $metric ) { + $this->assertArrayStructure( $metricStruct, $metric, $name ); + + foreach ( $metricStruct as $key => $type ) { + if ( in_array( $key, $statsMetrics ) ) { + $this->assertArrayStructure( + $statStruct, $metric[$key], $key + ); + $this->assertLessThanOrEqual( + $metric[$key]['total'], $metric[$key]['exclusive'] + ); + } + } + } + } + + /** + * @covers XhprofData::getCallers + * @covers XhprofData::getCallees + * @uses XhprofData + */ + public function testEdges() { + $xhprofData = $this->getXhprofDataFixture(); + $this->assertSame( [], $xhprofData->getCallers( 'main()' ) ); + $this->assertSame( [ 'foo', 'xhprof_disable' ], + $xhprofData->getCallees( 'main()' ) + ); + $this->assertSame( [ 'main()' ], + $xhprofData->getCallers( 'foo' ) + ); + $this->assertSame( [], $xhprofData->getCallees( 'strlen' ) ); + } + + /** + * @covers XhprofData::getCriticalPath + * @uses XhprofData + */ + public function testCriticalPath() { + $xhprofData = $this->getXhprofDataFixture(); + $path = $xhprofData->getCriticalPath(); + + $last = null; + foreach ( $path as $key => $value ) { + list( $func, $call ) = XhprofData::splitKey( $key ); + $this->assertSame( $last, $func ); + $last = $call; + } + $this->assertSame( $last, 'bar@1' ); + } + + /** + * Get an Xhprof instance that has been primed with a set of known testing + * data. Tests for the Xhprof class should laregly be concerned with + * evaluating the manipulations of the data collected by xhprof rather + * than the data collection process itself. + * + * The returned Xhprof instance primed will be with a data set created by + * running this trivial program using the PECL xhprof implementation: + * @code + * function bar( $x ) { + * if ( $x > 0 ) { + * bar($x - 1); + * } + * } + * function foo() { + * for ( $idx = 0; $idx < 2; $idx++ ) { + * bar( $idx ); + * $x = strlen( 'abc' ); + * } + * } + * xhprof_enable( XHPROF_FLAGS_CPU | XHPROF_FLAGS_MEMORY ); + * foo(); + * $x = xhprof_disable(); + * var_export( $x ); + * @endcode + * + * @return Xhprof + */ + protected function getXhprofDataFixture( array $opts = [] ) { + return new XhprofData( [ + 'foo==>bar' => [ + 'ct' => 2, + 'wt' => 57, + 'cpu' => 92, + 'mu' => 1896, + 'pmu' => 0, + ], + 'foo==>strlen' => [ + 'ct' => 2, + 'wt' => 21, + 'cpu' => 141, + 'mu' => 752, + 'pmu' => 0, + ], + 'bar==>bar@1' => [ + 'ct' => 1, + 'wt' => 18, + 'cpu' => 19, + 'mu' => 752, + 'pmu' => 0, + ], + 'main()==>foo' => [ + 'ct' => 1, + 'wt' => 304, + 'cpu' => 307, + 'mu' => 4008, + 'pmu' => 0, + ], + 'main()==>xhprof_disable' => [ + 'ct' => 1, + 'wt' => 8, + 'cpu' => 10, + 'mu' => 768, + 'pmu' => 392, + ], + 'main()' => [ + 'ct' => 1, + 'wt' => 353, + 'cpu' => 351, + 'mu' => 6112, + 'pmu' => 1424, + ], + ], $opts ); + } + + /** + * Assert that the given array has the described structure. + * + * @param array $struct Array of key => type mappings + * @param array $actual Array to check + * @param string $label + */ + protected function assertArrayStructure( $struct, $actual, $label = null ) { + $this->assertInternalType( 'array', $actual, $label ); + $this->assertCount( count( $struct ), $actual, $label ); + foreach ( $struct as $key => $type ) { + $this->assertArrayHasKey( $key, $actual ); + $this->assertInternalType( $type, $actual[$key] ); + } + } +} diff --git a/tests/phpunit/includes/libs/XhprofTest.php b/tests/phpunit/includes/libs/XhprofTest.php index 22925bfdfe..6748115423 100644 --- a/tests/phpunit/includes/libs/XhprofTest.php +++ b/tests/phpunit/includes/libs/XhprofTest.php @@ -18,303 +18,20 @@ * @file */ -/** - * @uses Xhprof - * @uses AutoLoader - * @author Bryan Davis - * @copyright © 2014 Bryan Davis and Wikimedia Foundation. - * @since 1.25 - */ class XhprofTest extends PHPUnit_Framework_TestCase { - - public function setUp() { - if ( !function_exists( 'xhprof_enable' ) ) { - $this->markTestSkipped( 'No xhprof support detected.' ); - } - } - - /** - * @covers Xhprof::splitKey - * @dataProvider provideSplitKey - */ - public function testSplitKey( $key, $expect ) { - $this->assertSame( $expect, Xhprof::splitKey( $key ) ); - } - - public function provideSplitKey() { - return [ - [ 'main()', [ null, 'main()' ] ], - [ 'foo==>bar', [ 'foo', 'bar' ] ], - [ 'bar@1==>bar@2', [ 'bar@1', 'bar@2' ] ], - [ 'foo==>bar==>baz', [ 'foo', 'bar==>baz' ] ], - [ '==>bar', [ '', 'bar' ] ], - [ '', [ null, '' ] ], - ]; - } - - /** - * @covers Xhprof::__construct - * @covers Xhprof::stop - * @covers Xhprof::getRawData - * @dataProvider provideRawData - */ - public function testRawData( $flags, $keys ) { - $xhprof = new Xhprof( [ 'flags' => $flags ] ); - $raw = $xhprof->getRawData(); - $this->assertArrayHasKey( 'main()', $raw ); - foreach ( $keys as $key ) { - $this->assertArrayHasKey( $key, $raw['main()'] ); - } - } - - public function provideRawData() { - $tests = [ - [ 0, [ 'ct', 'wt' ] ], - ]; - - if ( defined( 'XHPROF_FLAGS_CPU' ) && defined( 'XHPROF_FLAGS_CPU' ) ) { - $tests[] = [ XHPROF_FLAGS_MEMORY, [ - 'ct', 'wt', 'mu', 'pmu', - ] ]; - $tests[] = [ XHPROF_FLAGS_CPU, [ - 'ct', 'wt', 'cpu', - ] ]; - $tests[] = [ XHPROF_FLAGS_MEMORY | XHPROF_FLAGS_CPU, [ - 'ct', 'wt', 'mu', 'pmu', 'cpu', - ] ]; - } - - return $tests; - } - - /** - * @covers Xhprof::pruneData - */ - public function testInclude() { - $xhprof = $this->getXhprofFixture( [ - 'include' => [ 'main()' ], - ] ); - $raw = $xhprof->getRawData(); - $this->assertArrayHasKey( 'main()', $raw ); - $this->assertArrayHasKey( 'main()==>foo', $raw ); - $this->assertArrayHasKey( 'main()==>xhprof_disable', $raw ); - $this->assertSame( 3, count( $raw ) ); - } - /** - * Validate the structure of data returned by - * Xhprof::getInclusiveMetrics(). This acts as a guard against unexpected - * structural changes to the returned data in lieu of using a more heavy - * weight typed response object. + * Trying to enable Xhprof when it is already enabled causes an exception + * to be thrown. * - * @covers Xhprof::getInclusiveMetrics - */ - public function testInclusiveMetricsStructure() { - $metricStruct = [ - 'ct' => 'int', - 'wt' => 'array', - 'cpu' => 'array', - 'mu' => 'array', - 'pmu' => 'array', - ]; - $statStruct = [ - 'total' => 'numeric', - 'min' => 'numeric', - 'mean' => 'numeric', - 'max' => 'numeric', - 'variance' => 'numeric', - 'percent' => 'numeric', - ]; - - $xhprof = $this->getXhprofFixture(); - $metrics = $xhprof->getInclusiveMetrics(); - - foreach ( $metrics as $name => $metric ) { - $this->assertArrayStructure( $metricStruct, $metric ); - - foreach ( $metricStruct as $key => $type ) { - if ( $type === 'array' ) { - $this->assertArrayStructure( $statStruct, $metric[$key] ); - if ( $name === 'main()' ) { - $this->assertEquals( 100, $metric[$key]['percent'] ); - } - } - } - } - } - - /** - * Validate the structure of data returned by - * Xhprof::getCompleteMetrics(). This acts as a guard against unexpected - * structural changes to the returned data in lieu of using a more heavy - * weight typed response object. - * - * @covers Xhprof::getCompleteMetrics - */ - public function testCompleteMetricsStructure() { - $metricStruct = [ - 'ct' => 'int', - 'wt' => 'array', - 'cpu' => 'array', - 'mu' => 'array', - 'pmu' => 'array', - 'calls' => 'array', - 'subcalls' => 'array', - ]; - $statsMetrics = [ 'wt', 'cpu', 'mu', 'pmu' ]; - $statStruct = [ - 'total' => 'numeric', - 'min' => 'numeric', - 'mean' => 'numeric', - 'max' => 'numeric', - 'variance' => 'numeric', - 'percent' => 'numeric', - 'exclusive' => 'numeric', - ]; - - $xhprof = $this->getXhprofFixture(); - $metrics = $xhprof->getCompleteMetrics(); - - foreach ( $metrics as $name => $metric ) { - $this->assertArrayStructure( $metricStruct, $metric, $name ); - - foreach ( $metricStruct as $key => $type ) { - if ( in_array( $key, $statsMetrics ) ) { - $this->assertArrayStructure( - $statStruct, $metric[$key], $key - ); - $this->assertLessThanOrEqual( - $metric[$key]['total'], $metric[$key]['exclusive'] - ); - } - } - } - } - - /** - * @covers Xhprof::getCallers - * @covers Xhprof::getCallees - * @uses Xhprof - */ - public function testEdges() { - $xhprof = $this->getXhprofFixture(); - $this->assertSame( [], $xhprof->getCallers( 'main()' ) ); - $this->assertSame( [ 'foo', 'xhprof_disable' ], - $xhprof->getCallees( 'main()' ) - ); - $this->assertSame( [ 'main()' ], - $xhprof->getCallers( 'foo' ) - ); - $this->assertSame( [], $xhprof->getCallees( 'strlen' ) ); - } - - /** - * @covers Xhprof::getCriticalPath - * @uses Xhprof - */ - public function testCriticalPath() { - $xhprof = $this->getXhprofFixture(); - $path = $xhprof->getCriticalPath(); - - $last = null; - foreach ( $path as $key => $value ) { - list( $func, $call ) = Xhprof::splitKey( $key ); - $this->assertSame( $last, $func ); - $last = $call; - } - $this->assertSame( $last, 'bar@1' ); - } - - /** - * Get an Xhprof instance that has been primed with a set of known testing - * data. Tests for the Xhprof class should laregly be concerned with - * evaluating the manipulations of the data collected by xhprof rather - * than the data collection process itself. - * - * The returned Xhprof instance primed will be with a data set created by - * running this trivial program using the PECL xhprof implementation: - * @code - * function bar( $x ) { - * if ( $x > 0 ) { - * bar($x - 1); - * } - * } - * function foo() { - * for ( $idx = 0; $idx < 2; $idx++ ) { - * bar( $idx ); - * $x = strlen( 'abc' ); - * } - * } - * xhprof_enable( XHPROF_FLAGS_CPU | XHPROF_FLAGS_MEMORY ); - * foo(); - * $x = xhprof_disable(); - * var_export( $x ); - * @endcode - * - * @return Xhprof - */ - protected function getXhprofFixture( array $opts = [] ) { - $xhprof = new Xhprof( $opts ); - $xhprof->loadRawData( [ - 'foo==>bar' => [ - 'ct' => 2, - 'wt' => 57, - 'cpu' => 92, - 'mu' => 1896, - 'pmu' => 0, - ], - 'foo==>strlen' => [ - 'ct' => 2, - 'wt' => 21, - 'cpu' => 141, - 'mu' => 752, - 'pmu' => 0, - ], - 'bar==>bar@1' => [ - 'ct' => 1, - 'wt' => 18, - 'cpu' => 19, - 'mu' => 752, - 'pmu' => 0, - ], - 'main()==>foo' => [ - 'ct' => 1, - 'wt' => 304, - 'cpu' => 307, - 'mu' => 4008, - 'pmu' => 0, - ], - 'main()==>xhprof_disable' => [ - 'ct' => 1, - 'wt' => 8, - 'cpu' => 10, - 'mu' => 768, - 'pmu' => 392, - ], - 'main()' => [ - 'ct' => 1, - 'wt' => 353, - 'cpu' => 351, - 'mu' => 6112, - 'pmu' => 1424, - ], - ] ); - return $xhprof; - } - - /** - * Assert that the given array has the described structure. - * - * @param array $struct Array of key => type mappings - * @param array $actual Array to check - * @param string $label - */ - protected function assertArrayStructure( $struct, $actual, $label = null ) { - $this->assertInternalType( 'array', $actual, $label ); - $this->assertCount( count( $struct ), $actual, $label ); - foreach ( $struct as $key => $type ) { - $this->assertArrayHasKey( $key, $actual ); - $this->assertInternalType( $type, $actual[$key] ); - } + * @expectedException Exception + * @expectedExceptionMessage already enabled + * @covers Xhprof::enable + */ + public function testEnable() { + $xhprof = new ReflectionClass( 'Xhprof' ); + $enabled = $xhprof->getProperty( 'enabled' ); + $enabled->setAccessible( true ); + $enabled->setValue( true ); + $xhprof->getMethod( 'enable' )->invoke( null ); } } diff --git a/tests/phpunit/includes/libs/XmlTypeCheckTest.php b/tests/phpunit/includes/libs/XmlTypeCheckTest.php index 80efcb3172..7f9a772aa7 100644 --- a/tests/phpunit/includes/libs/XmlTypeCheckTest.php +++ b/tests/phpunit/includes/libs/XmlTypeCheckTest.php @@ -30,6 +30,32 @@ class XmlTypeCheckTest extends PHPUnit_Framework_TestCase { $this->assertFalse( $testXML->wellFormed ); } + /** + * Verify we check for recursive entity DOS + * + * (If the DOS isn't properly handled, the test runner will probably go OOM...) + */ + public function testRecursiveEntity() { + $xml = <<<'XML' + + + + + + + + + +]> + +&test; + +XML; + $check = XmlTypeCheck::newFromString( $xml ); + $this->assertFalse( $check->wellFormed ); + } + /** * @covers XMLTypeCheck::processingInstructionHandler */ diff --git a/tests/phpunit/includes/libs/composer/ComposerJsonTest.php b/tests/phpunit/includes/libs/composer/ComposerJsonTest.php index 2072752d67..ded5f8fe09 100644 --- a/tests/phpunit/includes/libs/composer/ComposerJsonTest.php +++ b/tests/phpunit/includes/libs/composer/ComposerJsonTest.php @@ -11,23 +11,8 @@ class ComposerJsonTest extends MediaWikiTestCase { $this->json2 = "$IP/tests/phpunit/data/composer/new-composer.json"; } - public static function provideGetHash() { - return [ - [ 'json', 'cc6e7fc565b246cb30b0cac103a2b31e' ], - [ 'json2', '19921dd1fc457f1b00561da932432001' ], - ]; - } - - /** - * @dataProvider provideGetHash - * @covers ComposerJson::getHash - */ - public function testIsHashUpToDate( $file, $expected ) { - $json = new ComposerJson( $this->$file ); - $this->assertEquals( $expected, $json->getHash() ); - } - /** + * @covers ComposerJson::__construct * @covers ComposerJson::getRequiredDependencies */ public function testGetRequiredDependencies() { diff --git a/tests/phpunit/includes/libs/composer/ComposerLockTest.php b/tests/phpunit/includes/libs/composer/ComposerLockTest.php index 75eb62caa8..eef7e274a2 100644 --- a/tests/phpunit/includes/libs/composer/ComposerLockTest.php +++ b/tests/phpunit/includes/libs/composer/ComposerLockTest.php @@ -11,14 +11,7 @@ class ComposerLockTest extends MediaWikiTestCase { } /** - * @covers ComposerLock::getHash - */ - public function testGetHash() { - $lock = new ComposerLock( $this->lock ); - $this->assertEquals( 'a3bb80b0ac4c4a31e52574d48c032923', $lock->getHash() ); - } - - /** + * @covers ComposerLock::__construct * @covers ComposerLock::getInstalledDependencies */ public function testGetInstalledDependencies() { diff --git a/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php b/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php new file mode 100644 index 0000000000..85927a393d --- /dev/null +++ b/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php @@ -0,0 +1,62 @@ +mimeAnalyzer = new MimeAnalyzer( [ + 'infoFile' => $IP . "/includes/libs/mime/mime.info", + 'typeFile' => $IP . "/includes/libs/mime/mime.types", + 'xmlTypes' => [ + 'http://www.w3.org/2000/svg:svg' => 'image/svg+xml', + 'svg' => 'image/svg+xml', + 'http://www.lysator.liu.se/~alla/dia/:diagram' => 'application/x-dia-diagram', + 'http://www.w3.org/1999/xhtml:html' => 'text/html', // application/xhtml+xml? + 'html' => 'text/html', // application/xhtml+xml? + ] + ] ); + parent::setUp(); + } + + /** + * @dataProvider providerImproveTypeFromExtension + * @param string $ext File extension (no leading dot) + * @param string $oldMime Initially detected MIME + * @param string $expectedMime MIME type after taking extension into account + */ + function testImproveTypeFromExtension( $ext, $oldMime, $expectedMime ) { + $actualMime = $this->mimeAnalyzer->improveTypeFromExtension( $oldMime, $ext ); + $this->assertEquals( $expectedMime, $actualMime ); + } + + function providerImproveTypeFromExtension() { + return [ + [ 'gif', 'image/gif', 'image/gif' ], + [ 'gif', 'unknown/unknown', 'unknown/unknown' ], + [ 'wrl', 'unknown/unknown', 'model/vrml' ], + [ 'txt', 'text/plain', 'text/plain' ], + [ 'csv', 'text/plain', 'text/csv' ], + [ 'tsv', 'text/plain', 'text/tab-separated-values' ], + [ 'js', 'text/javascript', 'application/javascript' ], + [ 'js', 'application/x-javascript', 'application/javascript' ], + [ 'json', 'text/plain', 'application/json' ], + [ 'foo', 'application/x-opc+zip', 'application/zip' ], + [ 'docx', 'application/x-opc+zip', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ], + [ 'djvu', 'image/x-djvu', 'image/vnd.djvu' ], + [ 'wav', 'audio/wav', 'audio/wav' ], + ]; + } + + /** + * Test to make sure that encoder=ffmpeg2theora doesn't trigger + * MEDIATYPE_VIDEO (bug 63584) + */ + function testOggRecognize() { + $oggFile = __DIR__ . '/../../../data/media/say-test.ogg'; + $actualType = $this->mimeAnalyzer->getMediaType( $oggFile, 'application/ogg' ); + $this->assertEquals( $actualType, MEDIATYPE_AUDIO ); + } +} diff --git a/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php index a8beb91518..a1afa77726 100644 --- a/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php +++ b/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php @@ -1,4 +1,7 @@ * @group BagOStuff @@ -138,6 +141,20 @@ class BagOStuffTest extends MediaWikiTestCase { } } + /** + * @covers BagOStuff::changeTTL + */ + public function testChangeTTL() { + $key = wfMemcKey( 'test' ); + $value = 'meow'; + + $this->cache->add( $key, $value ); + $this->assertTrue( $this->cache->changeTTL( $key, 5 ) ); + $this->assertEquals( $this->cache->get( $key ), $value ); + $this->cache->delete( $key ); + $this->assertFalse( $this->cache->changeTTL( $key, 5 ) ); + } + /** * @covers BagOStuff::add */ @@ -237,20 +254,20 @@ class BagOStuffTest extends MediaWikiTestCase { $value1 = $this->cache->getScopedLock( $key, 0 ); $value2 = $this->cache->getScopedLock( $key, 0 ); - $this->assertType( 'ScopedCallback', $value1, 'First call returned lock' ); + $this->assertType( ScopedCallback::class, $value1, 'First call returned lock' ); $this->assertNull( $value2, 'Duplicate call returned no lock' ); unset( $value1 ); $value3 = $this->cache->getScopedLock( $key, 0 ); - $this->assertType( 'ScopedCallback', $value3, 'Lock returned callback after release' ); + $this->assertType( ScopedCallback::class, $value3, 'Lock returned callback after release' ); unset( $value3 ); $value1 = $this->cache->getScopedLock( $key, 0, 5, 'reentry' ); $value2 = $this->cache->getScopedLock( $key, 0, 5, 'reentry' ); - $this->assertType( 'ScopedCallback', $value1, 'First reentrant call returned lock' ); - $this->assertType( 'ScopedCallback', $value1, 'Second reentrant call returned lock' ); + $this->assertType( ScopedCallback::class, $value1, 'First reentrant call returned lock' ); + $this->assertType( ScopedCallback::class, $value1, 'Second reentrant call returned lock' ); } /** diff --git a/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php index 7fe8055444..a01cc6b7b8 100644 --- a/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php +++ b/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php @@ -5,6 +5,10 @@ */ class CachedBagOStuffTest extends PHPUnit_Framework_TestCase { + /** + * @covers CachedBagOStuff::__construct + * @covers CachedBagOStuff::doGet + */ public function testGetFromBackend() { $backend = new HashBagOStuff; $cache = new CachedBagOStuff( $backend ); @@ -16,6 +20,10 @@ class CachedBagOStuffTest extends PHPUnit_Framework_TestCase { $this->assertEquals( 'bar', $cache->get( 'foo' ), 'cached' ); } + /** + * @covers CachedBagOStuff::set + * @covers CachedBagOStuff::delete + */ public function testSetAndDelete() { $backend = new HashBagOStuff; $cache = new CachedBagOStuff( $backend ); @@ -30,6 +38,10 @@ class CachedBagOStuffTest extends PHPUnit_Framework_TestCase { } } + /** + * @covers CachedBagOStuff::set + * @covers CachedBagOStuff::delete + */ public function testWriteCacheOnly() { $backend = new HashBagOStuff; $cache = new CachedBagOStuff( $backend ); @@ -50,6 +62,9 @@ class CachedBagOStuffTest extends PHPUnit_Framework_TestCase { $this->assertEquals( 'old', $cache->get( 'foo' ) ); // Reloaded from backend } + /** + * @covers CachedBagOStuff::doGet + */ public function testCacheBackendMisses() { $backend = new HashBagOStuff; $cache = new CachedBagOStuff( $backend ); diff --git a/tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php index fce09ae58d..c4db0cf8bf 100644 --- a/tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php +++ b/tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php @@ -5,6 +5,9 @@ */ class HashBagOStuffTest extends PHPUnit_Framework_TestCase { + /** + * @covers HashBagOStuff::delete + */ public function testDelete() { $cache = new HashBagOStuff(); for ( $i = 0; $i < 10; $i++ ) { @@ -15,6 +18,9 @@ class HashBagOStuffTest extends PHPUnit_Framework_TestCase { } } + /** + * @covers HashBagOStuff::clear + */ public function testClear() { $cache = new HashBagOStuff(); for ( $i = 0; $i < 10; $i++ ) { @@ -27,6 +33,10 @@ class HashBagOStuffTest extends PHPUnit_Framework_TestCase { } } + /** + * @covers HashBagOStuff::doGet + * @covers HashBagOStuff::expire + */ public function testExpire() { $cache = new HashBagOStuff(); $cacheInternal = TestingAccessWrapper::newFromObject( $cache ); @@ -45,6 +55,9 @@ class HashBagOStuffTest extends PHPUnit_Framework_TestCase { /** * Ensure maxKeys eviction prefers keeping new keys. + * + * @covers HashBagOStuff::__construct + * @covers HashBagOStuff::set */ public function testEvictionAdd() { $cache = new HashBagOStuff( [ 'maxKeys' => 10 ] ); @@ -62,6 +75,9 @@ class HashBagOStuffTest extends PHPUnit_Framework_TestCase { /** * Ensure maxKeys eviction prefers recently set keys * even if the keys pre-exist. + * + * @covers HashBagOStuff::__construct + * @covers HashBagOStuff::set */ public function testEvictionSet() { $cache = new HashBagOStuff( [ 'maxKeys' => 3 ] ); @@ -85,6 +101,10 @@ class HashBagOStuffTest extends PHPUnit_Framework_TestCase { /** * Ensure maxKeys eviction prefers recently retrieved keys (LRU). + * + * @covers HashBagOStuff::__construct + * @covers HashBagOStuff::doGet + * @covers HashBagOStuff::hasKey */ public function testEvictionGet() { $cache = new HashBagOStuff( [ 'maxKeys' => 3 ] ); diff --git a/tests/phpunit/includes/libs/objectcache/MultiWriteBagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/MultiWriteBagOStuffTest.php index 6df74d6c39..38d63e341c 100644 --- a/tests/phpunit/includes/libs/objectcache/MultiWriteBagOStuffTest.php +++ b/tests/phpunit/includes/libs/objectcache/MultiWriteBagOStuffTest.php @@ -23,6 +23,10 @@ class MultiWriteBagOStuffTest extends MediaWikiTestCase { ] ); } + /** + * @covers MultiWriteBagOStuff::set + * @covers MultiWriteBagOStuff::doWrite + */ public function testSetImmediate() { $key = wfRandomString(); $value = wfRandomString(); @@ -34,6 +38,9 @@ class MultiWriteBagOStuffTest extends MediaWikiTestCase { $this->assertEquals( $value, $this->cache2->get( $key ), 'Written to tier 2' ); } + /** + * @covers MultiWriteBagOStuff + */ public function testSyncMerge() { $key = wfRandomString(); $value = wfRandomString(); @@ -69,6 +76,9 @@ class MultiWriteBagOStuffTest extends MediaWikiTestCase { $dbw->commit(); } + /** + * @covers MultiWriteBagOStuff::set + */ public function testSetDelayed() { $key = wfRandomString(); $value = wfRandomString(); diff --git a/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php b/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php index 266e0250e6..aa46c966ad 100644 --- a/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php +++ b/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php @@ -1,6 +1,6 @@ getCliArg( 'use-wanobjectcache' ) ) { - $name = $this->getCliArg( 'use-wanobjectcache' ); - - $this->cache = ObjectCache::getWANInstance( $name ); - } else { - $this->cache = new WANObjectCache( [ - 'cache' => new HashBagOStuff(), - 'pool' => 'testcache-hash', - 'relayer' => new EventRelayerNull( [] ) - ] ); - } + $this->cache = new WANObjectCache( [ + 'cache' => new HashBagOStuff(), + 'pool' => 'testcache-hash', + 'relayer' => new EventRelayerNull( [] ) + ] ); $wanCache = TestingAccessWrapper::newFromObject( $this->cache ); + /** @noinspection PhpUndefinedFieldInspection */ $this->internalCache = $wanCache->cache; } @@ -29,21 +24,31 @@ class WANObjectCacheTest extends MediaWikiTestCase { * @dataProvider provideSetAndGet * @covers WANObjectCache::set() * @covers WANObjectCache::get() + * @covers WANObjectCache::makeKey() * @param mixed $value * @param integer $ttl */ public function testSetAndGet( $value, $ttl ) { - $key = wfRandomString(); + $curTTL = null; + $asOf = null; + $key = $this->cache->makeKey( 'x', wfRandomString() ); + + $this->cache->get( $key, $curTTL, [], $asOf ); + $this->assertNull( $curTTL, "Current TTL is null" ); + $this->assertNull( $asOf, "Current as-of-time is infinite" ); + + $t = microtime( true ); $this->cache->set( $key, $value, $ttl ); - $curTTL = null; - $this->assertEquals( $value, $this->cache->get( $key, $curTTL ) ); + $this->assertEquals( $value, $this->cache->get( $key, $curTTL, [], $asOf ) ); if ( is_infinite( $ttl ) || $ttl == 0 ) { $this->assertTrue( is_infinite( $curTTL ), "Current TTL is infinite" ); } else { $this->assertGreaterThan( 0, $curTTL, "Current TTL > 0" ); $this->assertLessThanOrEqual( $ttl, $curTTL, "Current TTL < nominal TTL" ); } + $this->assertGreaterThanOrEqual( $t - 1, $asOf, "As-of-time in range of set() time" ); + $this->assertLessThanOrEqual( $t + 1, $asOf, "As-of-time in range of set() time" ); } public static function provideSetAndGet() { @@ -62,9 +67,10 @@ class WANObjectCacheTest extends MediaWikiTestCase { /** * @covers WANObjectCache::get() + * @covers WANObjectCache::makeGlobalKey() */ public function testGetNotExists() { - $key = wfRandomString(); + $key = $this->cache->makeGlobalKey( 'y', wfRandomString(), 'p' ); $curTTL = null; $value = $this->cache->get( $key, $curTTL ); @@ -96,11 +102,68 @@ class WANObjectCacheTest extends MediaWikiTestCase { $this->assertFalse( $this->cache->get( $key ), "Stale set() value ignored" ); } + public function testProcessCache() { + $hit = 0; + $callback = function () use ( &$hit ) { + ++$hit; + return 42; + }; + $keys = [ wfRandomString(), wfRandomString(), wfRandomString() ]; + $groups = [ 'thiscache:1', 'thatcache:1', 'somecache:1' ]; + + foreach ( $keys as $i => $key ) { + $this->cache->getWithSetCallback( + $key, 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + } + $this->assertEquals( 3, $hit ); + + foreach ( $keys as $i => $key ) { + $this->cache->getWithSetCallback( + $key, 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + } + $this->assertEquals( 3, $hit, "Values cached" ); + + foreach ( $keys as $i => $key ) { + $this->cache->getWithSetCallback( + "$key-2", 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + } + $this->assertEquals( 6, $hit ); + + foreach ( $keys as $i => $key ) { + $this->cache->getWithSetCallback( + "$key-2", 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + } + $this->assertEquals( 6, $hit, "New values cached" ); + + foreach ( $keys as $i => $key ) { + $this->cache->delete( $key ); + $this->cache->getWithSetCallback( + $key, 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + } + $this->assertEquals( 9, $hit, "Values evicted" ); + + $key = reset( $keys ); + // Get into cache + $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] ); + $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] ); + $this->assertEquals( 10, $hit, "Value cached" ); + $outerCallback = function () use ( &$callback, $key ) { + $v = $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] ); + + return 43 + $v; + }; + $this->cache->getWithSetCallback( $key, 100, $outerCallback ); + $this->assertEquals( 11, $hit, "Nested callback value process cache skipped" ); + } + /** + * @dataProvider getWithSetCallback_provider * @covers WANObjectCache::getWithSetCallback() * @covers WANObjectCache::doGetWithSetCallback() + * @param array $extOpts + * @param bool $versioned */ - public function testGetWithSetCallback() { + public function testGetWithSetCallback( array $extOpts, $versioned ) { $cache = $this->cache; $key = wfRandomString(); @@ -108,17 +171,25 @@ class WANObjectCacheTest extends MediaWikiTestCase { $cKey1 = wfRandomString(); $cKey2 = wfRandomString(); + $priorValue = null; + $priorAsOf = null; $wasSet = 0; - $func = function( $old, &$ttl ) use ( &$wasSet, $value ) { + $func = function( $old, &$ttl, &$opts, $asOf ) + use ( &$wasSet, &$priorValue, &$priorAsOf, $value ) + { ++$wasSet; + $priorValue = $old; + $priorAsOf = $asOf; $ttl = 20; // override with another value return $value; }; $wasSet = 0; - $v = $cache->getWithSetCallback( $key, 30, $func, [ 'lockTSE' => 5 ] ); + $v = $cache->getWithSetCallback( $key, 30, $func, [ 'lockTSE' => 5 ] + $extOpts ); $this->assertEquals( $value, $v, "Value returned" ); $this->assertEquals( 1, $wasSet, "Value regenerated" ); + $this->assertFalse( $priorValue, "No prior value" ); + $this->assertNull( $priorAsOf, "No prior value" ); $curTTL = null; $cache->get( $key, $curTTL ); @@ -127,19 +198,22 @@ class WANObjectCacheTest extends MediaWikiTestCase { $wasSet = 0; $v = $cache->getWithSetCallback( $key, 30, $func, [ - 'lowTTL' => 0, - 'lockTSE' => 5, - ] ); + 'lowTTL' => 0, + 'lockTSE' => 5, + ] + $extOpts ); $this->assertEquals( $value, $v, "Value returned" ); $this->assertEquals( 0, $wasSet, "Value not regenerated" ); $priorTime = microtime( true ); usleep( 1 ); $wasSet = 0; - $v = $cache->getWithSetCallback( $key, 30, $func, - [ 'checkKeys' => [ $cKey1, $cKey2 ] ] ); + $v = $cache->getWithSetCallback( + $key, 30, $func, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts + ); $this->assertEquals( $value, $v, "Value returned" ); $this->assertEquals( 1, $wasSet, "Value regenerated due to check keys" ); + $this->assertEquals( $value, $priorValue, "Has prior value" ); + $this->assertInternalType( 'float', $priorAsOf, "Has prior value" ); $t1 = $cache->getCheckKeyTime( $cKey1 ); $this->assertGreaterThanOrEqual( $priorTime, $t1, 'Check keys generated on miss' ); $t2 = $cache->getCheckKeyTime( $cKey2 ); @@ -147,8 +221,9 @@ class WANObjectCacheTest extends MediaWikiTestCase { $priorTime = microtime( true ); $wasSet = 0; - $v = $cache->getWithSetCallback( $key, 30, $func, - [ 'checkKeys' => [ $cKey1, $cKey2 ] ] ); + $v = $cache->getWithSetCallback( + $key, 30, $func, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts + ); $this->assertEquals( $value, $v, "Value returned" ); $this->assertEquals( 1, $wasSet, "Value regenerated due to still-recent check keys" ); $t1 = $cache->getCheckKeyTime( $cKey1 ); @@ -158,19 +233,174 @@ class WANObjectCacheTest extends MediaWikiTestCase { $curTTL = null; $v = $cache->get( $key, $curTTL, [ $cKey1, $cKey2 ] ); - $this->assertEquals( $value, $v, "Value returned" ); + if ( $versioned ) { + $this->assertEquals( $value, $v[$cache::VFLD_DATA], "Value returned" ); + } else { + $this->assertEquals( $value, $v, "Value returned" ); + } $this->assertLessThanOrEqual( 0, $curTTL, "Value has current TTL < 0 due to check keys" ); $wasSet = 0; $key = wfRandomString(); - $v = $cache->getWithSetCallback( $key, 30, $func, [ 'pcTTL' => 5 ] ); + $v = $cache->getWithSetCallback( $key, 30, $func, [ 'pcTTL' => 5 ] + $extOpts ); $this->assertEquals( $value, $v, "Value returned" ); $cache->delete( $key ); - $v = $cache->getWithSetCallback( $key, 30, $func, [ 'pcTTL' => 5 ] ); + $v = $cache->getWithSetCallback( $key, 30, $func, [ 'pcTTL' => 5 ] + $extOpts ); $this->assertEquals( $value, $v, "Value still returned after deleted" ); $this->assertEquals( 1, $wasSet, "Value process cached while deleted" ); } + public static function getWithSetCallback_provider() { + return [ + [ [], false ], + [ [ 'version' => 1 ], true ] + ]; + } + + /** + * @dataProvider getMultiWithSetCallback_provider + * @covers WANObjectCache::getMultiWithSetCallback() + * @covers WANObjectCache::makeMultiKeys() + * @param array $extOpts + * @param bool $versioned + */ + public function testGetMultiWithSetCallback( array $extOpts, $versioned ) { + $cache = $this->cache; + + $keyA = wfRandomString(); + $keyB = wfRandomString(); + $keyC = wfRandomString(); + $cKey1 = wfRandomString(); + $cKey2 = wfRandomString(); + + $priorValue = null; + $priorAsOf = null; + $wasSet = 0; + $genFunc = function ( $id, $old, &$ttl, &$opts, $asOf ) use ( + &$wasSet, &$priorValue, &$priorAsOf + ) { + ++$wasSet; + $priorValue = $old; + $priorAsOf = $asOf; + $ttl = 20; // override with another value + return "@$id$"; + }; + + $wasSet = 0; + $keyedIds = new ArrayIterator( [ $keyA => 3353 ] ); + $value = "@3353$"; + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'lockTSE' => 5 ] + $extOpts ); + $this->assertEquals( $value, $v[$keyA], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + $this->assertFalse( $priorValue, "No prior value" ); + $this->assertNull( $priorAsOf, "No prior value" ); + + $curTTL = null; + $cache->get( $keyA, $curTTL ); + $this->assertLessThanOrEqual( 20, $curTTL, 'Current TTL between 19-20 (overriden)' ); + $this->assertGreaterThanOrEqual( 19, $curTTL, 'Current TTL between 19-20 (overriden)' ); + + $wasSet = 0; + $value = "@efef$"; + $keyedIds = new ArrayIterator( [ $keyB => 'efef' ] ); + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'lowTTL' => 0, 'lockTSE' => 5, ] + $extOpts ); + $this->assertEquals( $value, $v[$keyB], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'lowTTL' => 0, 'lockTSE' => 5, ] + $extOpts ); + $this->assertEquals( $value, $v[$keyB], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value not regenerated" ); + + $priorTime = microtime( true ); + usleep( 1 ); + $wasSet = 0; + $keyedIds = new ArrayIterator( [ $keyB => 'efef' ] ); + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts + ); + $this->assertEquals( $value, $v[$keyB], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated due to check keys" ); + $this->assertEquals( $value, $priorValue, "Has prior value" ); + $this->assertInternalType( 'float', $priorAsOf, "Has prior value" ); + $t1 = $cache->getCheckKeyTime( $cKey1 ); + $this->assertGreaterThanOrEqual( $priorTime, $t1, 'Check keys generated on miss' ); + $t2 = $cache->getCheckKeyTime( $cKey2 ); + $this->assertGreaterThanOrEqual( $priorTime, $t2, 'Check keys generated on miss' ); + + $priorTime = microtime( true ); + $value = "@43636$"; + $wasSet = 0; + $keyedIds = new ArrayIterator( [ $keyC => 43636 ] ); + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts + ); + $this->assertEquals( $value, $v[$keyC], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated due to still-recent check keys" ); + $t1 = $cache->getCheckKeyTime( $cKey1 ); + $this->assertLessThanOrEqual( $priorTime, $t1, 'Check keys did not change again' ); + $t2 = $cache->getCheckKeyTime( $cKey2 ); + $this->assertLessThanOrEqual( $priorTime, $t2, 'Check keys did not change again' ); + + $curTTL = null; + $v = $cache->get( $keyC, $curTTL, [ $cKey1, $cKey2 ] ); + if ( $versioned ) { + $this->assertEquals( $value, $v[$cache::VFLD_DATA], "Value returned" ); + } else { + $this->assertEquals( $value, $v, "Value returned" ); + } + $this->assertLessThanOrEqual( 0, $curTTL, "Value has current TTL < 0 due to check keys" ); + + $wasSet = 0; + $key = wfRandomString(); + $keyedIds = new ArrayIterator( [ $key => 242424 ] ); + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'pcTTL' => 5 ] + $extOpts ); + $this->assertEquals( "@{$keyedIds[$key]}$", $v[$key], "Value returned" ); + $cache->delete( $key ); + $keyedIds = new ArrayIterator( [ $key => 242424 ] ); + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'pcTTL' => 5 ] + $extOpts ); + $this->assertEquals( "@{$keyedIds[$key]}$", $v[$key], "Value still returned after deleted" ); + $this->assertEquals( 1, $wasSet, "Value process cached while deleted" ); + + $calls = 0; + $ids = [ 1, 2, 3, 4, 5, 6 ]; + $keyFunc = function ( $id, WANObjectCache $wanCache ) { + return $wanCache->makeKey( 'test', $id ); + }; + $keyedIds = $cache->makeMultiKeys( $ids, $keyFunc ); + $genFunc = function ( $id, $oldValue, &$ttl, array &$setops ) use ( &$calls ) { + ++$calls; + + return "val-{$id}"; + }; + $values = $cache->getMultiWithSetCallback( $keyedIds, 10, $genFunc ); + + $this->assertEquals( + [ "val-1", "val-2", "val-3", "val-4", "val-5", "val-6" ], + array_values( $values ), + "Correct values in correct order" + ); + $this->assertEquals( + array_map( $keyFunc, $ids, array_fill( 0, count( $ids ), $this->cache ) ), + array_keys( $values ), + "Correct keys in correct order" + ); + $this->assertEquals( count( $ids ), $calls ); + + $cache->getMultiWithSetCallback( $keyedIds, 10, $genFunc ); + $this->assertEquals( count( $ids ), $calls, "Values cached" ); + } + + public static function getMultiWithSetCallback_provider() { + return [ + [ [], false ], + [ [ 'version' => 1 ], true ] + ]; + } + /** * @covers WANObjectCache::getWithSetCallback() * @covers WANObjectCache::doGetWithSetCallback() @@ -181,8 +411,10 @@ class WANObjectCacheTest extends MediaWikiTestCase { $value = wfRandomString(); $calls = 0; - $func = function() use ( &$calls, $value ) { + $func = function() use ( &$calls, $value, $cache, $key ) { ++$calls; + // Immediately kill any mutex rather than waiting a second + $cache->delete( $cache::MUTEX_KEY_PREFIX . $key ); return $value; }; @@ -191,10 +423,24 @@ class WANObjectCacheTest extends MediaWikiTestCase { $this->assertEquals( 1, $calls, 'Value was populated' ); // Acquire a lock to verify that getWithSetCallback uses lockTSE properly - $this->internalCache->lock( $key, 0 ); - $ret = $cache->getWithSetCallback( $key, 30, $func, [ 'lockTSE' => 5 ] ); - $this->assertEquals( $value, $ret ); + $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + + $checkKeys = [ wfRandomString() ]; // new check keys => force misses + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'lockTSE' => 5, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Old value used' ); $this->assertEquals( 1, $calls, 'Callback was not used' ); + + $cache->delete( $key ); + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'lockTSE' => 5, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Callback was used; interim saved' ); + $this->assertEquals( 2, $calls, 'Callback was used; interim saved' ); + + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'lockTSE' => 5, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Callback was not used; used interim' ); + $this->assertEquals( 2, $calls, 'Callback was not used; used interim' ); } /** @@ -207,9 +453,11 @@ class WANObjectCacheTest extends MediaWikiTestCase { $value = wfRandomString(); $calls = 0; - $func = function( $oldValue, &$ttl, &$setOpts ) use ( &$calls, $value ) { + $func = function( $oldValue, &$ttl, &$setOpts ) use ( &$calls, $value, $cache, $key ) { ++$calls; $setOpts['since'] = microtime( true ) - 10; + // Immediately kill any mutex rather than waiting a second + $cache->delete( $cache::MUTEX_KEY_PREFIX . $key ); return $value; }; @@ -222,12 +470,67 @@ class WANObjectCacheTest extends MediaWikiTestCase { $this->assertEquals( 1, $calls, 'Value was generated' ); // Acquire a lock to verify that getWithSetCallback uses lockTSE properly - $this->internalCache->lock( $key, 0 ); + $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); $ret = $cache->getWithSetCallback( $key, 30, $func, [ 'lockTSE' => 5 ] ); $this->assertEquals( $value, $ret ); $this->assertEquals( 1, $calls, 'Callback was not used' ); } + /** + * @covers WANObjectCache::getWithSetCallback() + * @covers WANObjectCache::doGetWithSetCallback() + */ + public function testBusyValue() { + $cache = $this->cache; + $key = wfRandomString(); + $value = wfRandomString(); + $busyValue = wfRandomString(); + + $calls = 0; + $func = function() use ( &$calls, $value, $cache, $key ) { + ++$calls; + // Immediately kill any mutex rather than waiting a second + $cache->delete( $cache::MUTEX_KEY_PREFIX . $key ); + return $value; + }; + + $ret = $cache->getWithSetCallback( $key, 30, $func, [ 'busyValue' => $busyValue ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( 1, $calls, 'Value was populated' ); + + // Acquire a lock to verify that getWithSetCallback uses busyValue properly + $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + + $checkKeys = [ wfRandomString() ]; // new check keys => force misses + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Callback used' ); + $this->assertEquals( 2, $calls, 'Callback used' ); + + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'lockTSE' => 30, 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Old value used' ); + $this->assertEquals( 2, $calls, 'Callback was not used' ); + + $cache->delete( $key ); // no value at all anymore and still locked + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $busyValue, $ret, 'Callback was not used; used busy value' ); + $this->assertEquals( 2, $calls, 'Callback was not used; used busy value' ); + + $this->internalCache->delete( $cache::MUTEX_KEY_PREFIX . $key ); + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'lockTSE' => 30, 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Callback was used; saved interim' ); + $this->assertEquals( 3, $calls, 'Callback was used; saved interim' ); + + $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Callback was not used; used interim' ); + $this->assertEquals( 3, $calls, 'Callback was not used; used interim' ); + } + /** * @covers WANObjectCache::getMulti() */ @@ -434,6 +737,71 @@ class WANObjectCacheTest extends MediaWikiTestCase { $this->assertGreaterThan( 0, $curTTL, "Existing key has current TTL > 0" ); } + /** + * @dataProvider getWithSetCallback_versions_provider + * @param array $extOpts + * @param $versioned + */ + public function testGetWithSetCallback_versions( array $extOpts, $versioned ) { + $cache = $this->cache; + + $key = wfRandomString(); + $value = wfRandomString(); + + $wasSet = 0; + $func = function( $old, &$ttl ) use ( &$wasSet, $value ) { + ++$wasSet; + return $value; + }; + + // Set the main key (version N if versioned) + $wasSet = 0; + $v = $cache->getWithSetCallback( $key, 30, $func, $extOpts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + $cache->getWithSetCallback( $key, 30, $func, $extOpts ); + $this->assertEquals( 1, $wasSet, "Value not regenerated" ); + // Set the key for version N+1 (if versioned) + if ( $versioned ) { + $verOpts = [ 'version' => $extOpts['version'] + 1 ]; + + $wasSet = 0; + $v = $cache->getWithSetCallback( $key, 30, $func, $verOpts + $extOpts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + + $wasSet = 0; + $v = $cache->getWithSetCallback( $key, 30, $func, $verOpts + $extOpts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 0, $wasSet, "Value not regenerated" ); + } + + $wasSet = 0; + $cache->getWithSetCallback( $key, 30, $func, $extOpts ); + $this->assertEquals( 0, $wasSet, "Value not regenerated" ); + + $wasSet = 0; + $cache->delete( $key ); + $v = $cache->getWithSetCallback( $key, 30, $func, $extOpts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + + if ( $versioned ) { + $wasSet = 0; + $verOpts = [ 'version' => $extOpts['version'] + 1 ]; + $v = $cache->getWithSetCallback( $key, 30, $func, $verOpts + $extOpts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + } + } + + public static function getWithSetCallback_versions_provider() { + return [ + [ [], false ], + [ [ 'version' => 1 ], true ] + ]; + } + /** * @covers WANObjectCache::touchCheckKey() * @covers WANObjectCache::resetCheckKey() @@ -537,4 +905,59 @@ class WANObjectCacheTest extends MediaWikiTestCase { $this->cache->set( $key, $value, 30, $opts ); $this->assertEquals( false, $this->cache->get( $key ), "Pending value not written." ); } + + public function testMcRouterSupport() { + $localBag = $this->getMock( 'EmptyBagOStuff', [ 'set', 'delete' ] ); + $localBag->expects( $this->never() )->method( 'set' ); + $localBag->expects( $this->never() )->method( 'delete' ); + $wanCache = new WANObjectCache( [ + 'cache' => $localBag, + 'pool' => 'testcache-hash', + 'relayer' => new EventRelayerNull( [] ) + ] ); + $valFunc = function () { + return 1; + }; + + // None of these should use broadcasting commands (e.g. SET, DELETE) + $wanCache->get( 'x' ); + $wanCache->get( 'x', $ctl, [ 'check1' ] ); + $wanCache->getMulti( [ 'x', 'y' ] ); + $wanCache->getMulti( [ 'x', 'y' ], $ctls, [ 'check2' ] ); + $wanCache->getWithSetCallback( 'p', 30, $valFunc ); + $wanCache->getCheckKeyTime( 'zzz' ); + } + + /** + * @dataProvider provideAdaptiveTTL + * @covers WANObjectCache::adaptiveTTL() + * @param float|int $ago + * @param int $maxTTL + * @param int $minTTL + * @param float $factor + * @param int $adaptiveTTL + */ + public function testAdaptiveTTL( $ago, $maxTTL, $minTTL, $factor, $adaptiveTTL ) { + $mtime = $ago ? time() - $ago : $ago; + $margin = 5; + $ttl = $this->cache->adaptiveTTL( $mtime, $maxTTL, $minTTL, $factor ); + + $this->assertGreaterThanOrEqual( $adaptiveTTL - $margin, $ttl ); + $this->assertLessThanOrEqual( $adaptiveTTL + $margin, $ttl ); + + $ttl = $this->cache->adaptiveTTL( (string)$mtime, $maxTTL, $minTTL, $factor ); + + $this->assertGreaterThanOrEqual( $adaptiveTTL - $margin, $ttl ); + $this->assertLessThanOrEqual( $adaptiveTTL + $margin, $ttl ); + } + + public static function provideAdaptiveTTL() { + return [ + [ 3600, 900, 30, .2, 720 ], + [ 3600, 500, 30, .2, 500 ], + [ 3600, 86400, 800, .2, 800 ], + [ false, 86400, 800, .2, 800 ], + [ null, 86400, 800, .2, 800 ] + ]; + } } diff --git a/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php b/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php new file mode 100644 index 0000000000..1677851736 --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php @@ -0,0 +1,139 @@ +getMock( IDatabase::class ); + } + + /** + * @return LoadBalancer|PHPUnit_Framework_MockObject_MockObject + */ + private function getLoadBalancerMock() { + $lb = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + + return $lb; + } + + public function testGetReadConnection_nullGroups() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_REPLICA, [ 'group1' ], 'someDbName' ) + ->will( $this->returnValue( $database ) ); + + $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] ); + $actual = $manager->getReadConnection(); + + $this->assertSame( $database, $actual ); + } + + public function testGetReadConnection_withGroups() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_REPLICA, [ 'group2' ], 'someDbName' ) + ->will( $this->returnValue( $database ) ); + + $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] ); + $actual = $manager->getReadConnection( [ 'group2' ] ); + + $this->assertSame( $database, $actual ); + } + + public function testGetWriteConnection() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_MASTER, [ 'group1' ], 'someDbName' ) + ->will( $this->returnValue( $database ) ); + + $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] ); + $actual = $manager->getWriteConnection(); + + $this->assertSame( $database, $actual ); + } + + public function testReleaseConnection() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'reuseConnection' ) + ->with( $database ) + ->will( $this->returnValue( null ) ); + + $manager = new ConnectionManager( $lb ); + $manager->releaseConnection( $database ); + } + + public function testGetReadConnectionRef_nullGroups() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnectionRef' ) + ->with( DB_REPLICA, [ 'group1' ], 'someDbName' ) + ->will( $this->returnValue( $database ) ); + + $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] ); + $actual = $manager->getReadConnectionRef(); + + $this->assertSame( $database, $actual ); + } + + public function testGetReadConnectionRef_withGroups() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnectionRef' ) + ->with( DB_REPLICA, [ 'group2' ], 'someDbName' ) + ->will( $this->returnValue( $database ) ); + + $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] ); + $actual = $manager->getReadConnectionRef( [ 'group2' ] ); + + $this->assertSame( $database, $actual ); + } + + public function testGetWriteConnectionRef() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnectionRef' ) + ->with( DB_MASTER, [ 'group1' ], 'someDbName' ) + ->will( $this->returnValue( $database ) ); + + $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] ); + $actual = $manager->getWriteConnectionRef(); + + $this->assertSame( $database, $actual ); + } + +} diff --git a/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php b/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php new file mode 100644 index 0000000000..0d54659b5e --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php @@ -0,0 +1,108 @@ +getMock( IDatabase::class ); + } + + /** + * @return LoadBalancer|PHPUnit_Framework_MockObject_MockObject + */ + private function getLoadBalancerMock() { + $lb = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + + return $lb; + } + + public function testGetReadConnection() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_REPLICA ) + ->will( $this->returnValue( $database ) ); + + $manager = new SessionConsistentConnectionManager( $lb ); + $actual = $manager->getReadConnection(); + + $this->assertSame( $database, $actual ); + } + + public function testGetReadConnectionReturnsWriteDbOnForceMatser() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_MASTER ) + ->will( $this->returnValue( $database ) ); + + $manager = new SessionConsistentConnectionManager( $lb ); + $manager->prepareForUpdates(); + $actual = $manager->getReadConnection(); + + $this->assertSame( $database, $actual ); + } + + public function testGetWriteConnection() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_MASTER ) + ->will( $this->returnValue( $database ) ); + + $manager = new SessionConsistentConnectionManager( $lb ); + $actual = $manager->getWriteConnection(); + + $this->assertSame( $database, $actual ); + } + + public function testForceMaster() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_MASTER ) + ->will( $this->returnValue( $database ) ); + + $manager = new SessionConsistentConnectionManager( $lb ); + $manager->prepareForUpdates(); + $manager->getReadConnection(); + } + + public function testReleaseConnection() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'reuseConnection' ) + ->with( $database ) + ->will( $this->returnValue( null ) ); + + $manager = new SessionConsistentConnectionManager( $lb ); + $manager->releaseConnection( $database ); + } +} diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseDomainTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseDomainTest.php new file mode 100644 index 0000000000..d13fbf9341 --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseDomainTest.php @@ -0,0 +1,69 @@ +setExpectedException( InvalidArgumentException::class ); + } + + $domain = new DatabaseDomain( $db, $schema, $prefix ); + $this->assertInstanceOf( DatabaseDomain::class, $domain ); + $this->assertEquals( $db, $domain->getDatabase() ); + $this->assertEquals( $schema, $domain->getSchema() ); + $this->assertEquals( $prefix, $domain->getTablePrefix() ); + $this->assertEquals( $id, $domain->getId() ); + } + + public static function provideNewFromId() { + return [ + // basic + [ 'foo', 'foo', null, '' ], + // - + [ 'foo-bar', 'foo', null, 'bar' ], + [ 'foo-bar-baz', 'foo', 'bar', 'baz' ], + // ?h -> - + [ 'foo?hbar-baz-baa', 'foo-bar', 'baz', 'baa' ], + // ?? -> ? + [ 'foo??bar-baz-baa', 'foo?bar', 'baz', 'baa' ], + // ? is left alone + [ 'foo?bar-baz-baa', 'foo?bar', 'baz', 'baa' ], + // too many parts + [ 'foo-bar-baz-baa', '', '', '', true ], + ]; + } + + /** + * @dataProvider provideNewFromId + */ + public function testNewFromId( $id, $db, $schema, $prefix, $exception = false ) { + if ( $exception ) { + $this->setExpectedException( InvalidArgumentException::class ); + } + $domain = DatabaseDomain::newFromId( $id ); + $this->assertInstanceOf( DatabaseDomain::class, $domain ); + $this->assertEquals( $db, $domain->getDatabase() ); + $this->assertEquals( $schema, $domain->getSchema() ); + $this->assertEquals( $prefix, $domain->getTablePrefix() ); + } +} diff --git a/tests/phpunit/includes/libs/time/ConvertibleTimestampTest.php b/tests/phpunit/includes/libs/time/ConvertibleTimestampTest.php new file mode 100644 index 0000000000..d48caf37f0 --- /dev/null +++ b/tests/phpunit/includes/libs/time/ConvertibleTimestampTest.php @@ -0,0 +1,144 @@ +assertInternalType( 'string', $timestamp->getTimestamp() ); + $this->assertNotEmpty( $timestamp->getTimestamp() ); + $this->assertNotEquals( false, strtotime( $timestamp->getTimestamp( TS_MW ) ) ); + } + + /** + * @covers ConvertibleTimestamp::__toString + */ + public function testToString() { + $timestamp = new ConvertibleTimestamp( '1406833268' ); // Equivalent to 20140731190108 + $this->assertEquals( '1406833268', $timestamp->__toString() ); + } + + public static function provideValidTimestampDifferences() { + return [ + [ '1406833268', '1406833269', '00 00 00 01' ], + [ '1406833268', '1406833329', '00 00 01 01' ], + [ '1406833268', '1406836929', '00 01 01 01' ], + [ '1406833268', '1406923329', '01 01 01 01' ], + ]; + } + + /** + * @dataProvider provideValidTimestampDifferences + * @covers ConvertibleTimestamp::diff + */ + public function testDiff( $timestamp1, $timestamp2, $expected ) { + $timestamp1 = new ConvertibleTimestamp( $timestamp1 ); + $timestamp2 = new ConvertibleTimestamp( $timestamp2 ); + $diff = $timestamp1->diff( $timestamp2 ); + $this->assertEquals( $expected, $diff->format( '%D %H %I %S' ) ); + } + + /** + * Test parsing of valid timestamps and outputing to MW format. + * @dataProvider provideValidTimestamps + * @covers ConvertibleTimestamp::getTimestamp + */ + public function testValidParse( $format, $original, $expected ) { + $timestamp = new ConvertibleTimestamp( $original ); + $this->assertEquals( $expected, $timestamp->getTimestamp( TS_MW ) ); + } + + /** + * Test outputting valid timestamps to different formats. + * @dataProvider provideValidTimestamps + * @covers ConvertibleTimestamp::getTimestamp + */ + public function testValidOutput( $format, $expected, $original ) { + $timestamp = new ConvertibleTimestamp( $original ); + $this->assertEquals( $expected, (string)$timestamp->getTimestamp( $format ) ); + } + + /** + * Test an invalid timestamp. + * @expectedException TimestampException + * @covers ConvertibleTimestamp + */ + public function testInvalidParse() { + new ConvertibleTimestamp( "This is not a timestamp." ); + } + + /** + * @dataProvider provideValidTimestamps + * @covers ConvertibleTimestamp::convert + */ + public function testConvert( $format, $expected, $original ) { + $this->assertSame( $expected, ConvertibleTimestamp::convert( $format, $original ) ); + } + + /** + * Format an invalid timestamp. + * @covers ConvertibleTimestamp::convert + */ + public function testConvertInvalid() { + $this->assertSame( false, ConvertibleTimestamp::convert( 'Not a timestamp', 0 ) ); + } + + /** + * Test an out of range timestamp + * @dataProvider provideOutOfRangeTimestamps + * @expectedException TimestampException + * @covers ConvertibleTimestamp + */ + public function testOutOfRangeTimestamps( $format, $input ) { + $timestamp = new ConvertibleTimestamp( $input ); + $timestamp->getTimestamp( $format ); + } + + /** + * Test requesting an invalid output format. + * @expectedException TimestampException + * @covers ConvertibleTimestamp::getTimestamp + */ + public function testInvalidOutput() { + $timestamp = new ConvertibleTimestamp( '1343761268' ); + $timestamp->getTimestamp( 98 ); + } + + /** + * Returns a list of valid timestamps in the format: + * [ type, timestamp_of_type, timestamp_in_MW ] + */ + public static function provideValidTimestamps() { + return [ + // Various formats + [ TS_UNIX, '1343761268', '20120731190108' ], + [ TS_MW, '20120731190108', '20120731190108' ], + [ TS_DB, '2012-07-31 19:01:08', '20120731190108' ], + [ TS_ISO_8601, '2012-07-31T19:01:08Z', '20120731190108' ], + [ TS_ISO_8601_BASIC, '20120731T190108Z', '20120731190108' ], + [ TS_EXIF, '2012:07:31 19:01:08', '20120731190108' ], + [ TS_RFC2822, 'Tue, 31 Jul 2012 19:01:08 GMT', '20120731190108' ], + [ TS_ORACLE, '31-07-2012 19:01:08.000000', '20120731190108' ], + [ TS_POSTGRES, '2012-07-31 19:01:08 GMT', '20120731190108' ], + // Some extremes and weird values + [ TS_ISO_8601, '9999-12-31T23:59:59Z', '99991231235959' ], + [ TS_UNIX, '-62135596801', '00001231235959' ] + ]; + } + + /** + * Returns a list of out of range timestamps in the format: + * [ type, timestamp_of_type ] + */ + public static function provideOutOfRangeTimestamps() { + return [ + // Various formats + [ TS_MW, '-62167219201' ], // -0001-12-31T23:59:59Z + [ TS_MW, '253402300800' ], // 10000-01-01T00:00:00Z + ]; + } +} diff --git a/tests/phpunit/includes/libs/xmp/XMPTest.php b/tests/phpunit/includes/libs/xmp/XMPTest.php new file mode 100644 index 0000000000..ac52a39ffe --- /dev/null +++ b/tests/phpunit/includes/libs/xmp/XMPTest.php @@ -0,0 +1,226 @@ +markTestSkipped( "PHP extension 'exif' is not loaded, skipping." ); + } + } + + /** + * Put XMP in, compare what comes out... + * + * @param string $xmp The actual xml data. + * @param array $expected Expected result of parsing the xmp. + * @param string $info Short sentence on what's being tested. + * + * @throws Exception + * @dataProvider provideXMPParse + * + * @covers XMPReader::parse + */ + public function testXMPParse( $xmp, $expected, $info ) { + if ( !is_string( $xmp ) || !is_array( $expected ) ) { + throw new Exception( "Invalid data provided to " . __METHOD__ ); + } + $reader = new XMPReader; + $reader->parse( $xmp ); + $this->assertEquals( $expected, $reader->getResults(), $info, 0.0000000001 ); + } + + public static function provideXMPParse() { + $xmpPath = __DIR__ . '/../../../data/xmp/'; + $data = []; + + // $xmpFiles format: array of arrays with first arg file base name, + // with the actual file having .xmp on the end for the xmp + // and .result.php on the end for a php file containing the result + // array. Second argument is some info on what's being tested. + $xmpFiles = [ + [ '1', 'parseType=Resource test' ], + [ '2', 'Structure with mixed attribute and element props' ], + [ '3', 'Extra qualifiers (that should be ignored)' ], + [ '3-invalid', 'Test ignoring qualifiers that look like normal props' ], + [ '4', 'Flash as qualifier' ], + [ '5', 'Flash as qualifier 2' ], + [ '6', 'Multiple rdf:Description' ], + [ '7', 'Generic test of several property types' ], + [ 'flash', 'Test of Flash property' ], + [ 'invalid-child-not-struct', 'Test child props not in struct or ignored' ], + [ 'no-recognized-props', 'Test namespace and no recognized props' ], + [ 'no-namespace', 'Test non-namespaced attributes are ignored' ], + [ 'bag-for-seq', "Allow bag's instead of seq's. (bug 27105)" ], + [ 'utf16BE', 'UTF-16BE encoding' ], + [ 'utf16LE', 'UTF-16LE encoding' ], + [ 'utf32BE', 'UTF-32BE encoding' ], + [ 'utf32LE', 'UTF-32LE encoding' ], + [ 'xmpExt', 'Extended XMP missing second part' ], + [ 'gps', 'Handling of exif GPS parameters in XMP' ], + ]; + + $xmpFiles[] = [ 'doctype-included', 'XMP includes doctype' ]; + + foreach ( $xmpFiles as $file ) { + $xmp = file_get_contents( $xmpPath . $file[0] . '.xmp' ); + // I'm not sure if this is the best way to handle getting the + // result array, but it seems kind of big to put directly in the test + // file. + $result = null; + include $xmpPath . $file[0] . '.result.php'; + $data[] = [ $xmp, $result, '[' . $file[0] . '.xmp] ' . $file[1] ]; + } + + return $data; + } + + /** Test ExtendedXMP block support. (Used when the XMP has to be split + * over multiple jpeg segments, due to 64k size limit on jpeg segments. + * + * @todo This is based on what the standard says. Need to find a real + * world example file to double check the support for this is right. + * + * @covers XMPReader::parseExtended + */ + public function testExtendedXMP() { + $xmpPath = __DIR__ . '/../../../data/xmp/'; + $standardXMP = file_get_contents( $xmpPath . 'xmpExt.xmp' ); + $extendedXMP = file_get_contents( $xmpPath . 'xmpExt2.xmp' ); + + $md5sum = '28C74E0AC2D796886759006FBE2E57B7'; // of xmpExt2.xmp + $length = pack( 'N', strlen( $extendedXMP ) ); + $offset = pack( 'N', 0 ); + $extendedPacket = $md5sum . $length . $offset . $extendedXMP; + + $reader = new XMPReader(); + $reader->parse( $standardXMP ); + $reader->parseExtended( $extendedPacket ); + $actual = $reader->getResults(); + + $expected = [ + 'xmp-exif' => [ + 'DigitalZoomRatio' => '0/10', + 'Flash' => 9, + 'FNumber' => '2/10', + ] + ]; + + $this->assertEquals( $expected, $actual ); + } + + /** + * This test has an extended XMP block with a wrong guid (md5sum) + * and thus should only return the StandardXMP, not the ExtendedXMP. + * + * @covers XMPReader::parseExtended + */ + public function testExtendedXMPWithWrongGUID() { + $xmpPath = __DIR__ . '/../../../data/xmp/'; + $standardXMP = file_get_contents( $xmpPath . 'xmpExt.xmp' ); + $extendedXMP = file_get_contents( $xmpPath . 'xmpExt2.xmp' ); + + $md5sum = '28C74E0AC2D796886759006FBE2E57B9'; // Note last digit. + $length = pack( 'N', strlen( $extendedXMP ) ); + $offset = pack( 'N', 0 ); + $extendedPacket = $md5sum . $length . $offset . $extendedXMP; + + $reader = new XMPReader(); + $reader->parse( $standardXMP ); + $reader->parseExtended( $extendedPacket ); + $actual = $reader->getResults(); + + $expected = [ + 'xmp-exif' => [ + 'DigitalZoomRatio' => '0/10', + 'Flash' => 9, + ] + ]; + + $this->assertEquals( $expected, $actual ); + } + + /** + * Have a high offset to simulate a missing packet, + * which should cause it to ignore the ExtendedXMP packet. + * + * @covers XMPReader::parseExtended + */ + public function testExtendedXMPMissingPacket() { + $xmpPath = __DIR__ . '/../../../data/xmp/'; + $standardXMP = file_get_contents( $xmpPath . 'xmpExt.xmp' ); + $extendedXMP = file_get_contents( $xmpPath . 'xmpExt2.xmp' ); + + $md5sum = '28C74E0AC2D796886759006FBE2E57B7'; // of xmpExt2.xmp + $length = pack( 'N', strlen( $extendedXMP ) ); + $offset = pack( 'N', 2048 ); + $extendedPacket = $md5sum . $length . $offset . $extendedXMP; + + $reader = new XMPReader(); + $reader->parse( $standardXMP ); + $reader->parseExtended( $extendedPacket ); + $actual = $reader->getResults(); + + $expected = [ + 'xmp-exif' => [ + 'DigitalZoomRatio' => '0/10', + 'Flash' => 9, + ] + ]; + + $this->assertEquals( $expected, $actual ); + } + + /** + * Test for multi-section, hostile XML + * @covers XMPReader::checkParseSafety + */ + public function testCheckParseSafety() { + + // Test for detection + $xmpPath = __DIR__ . '/../../../data/xmp/'; + $file = fopen( $xmpPath . 'doctype-included.xmp', 'rb' ); + $valid = false; + $reader = new XMPReader(); + do { + $chunk = fread( $file, 10 ); + $valid = $reader->parse( $chunk, feof( $file ) ); + } while ( !feof( $file ) ); + $this->assertFalse( $valid, 'Check that doctype is detected in fragmented XML' ); + $this->assertEquals( + [], + $reader->getResults(), + 'Check that doctype is detected in fragmented XML' + ); + fclose( $file ); + unset( $reader ); + + // Test for false positives + $file = fopen( $xmpPath . 'doctype-not-included.xmp', 'rb' ); + $valid = false; + $reader = new XMPReader(); + do { + $chunk = fread( $file, 10 ); + $valid = $reader->parse( $chunk, feof( $file ) ); + } while ( !feof( $file ) ); + $this->assertTrue( + $valid, + 'Check for false-positive detecting doctype in fragmented XML' + ); + $this->assertEquals( + [ + 'xmp-exif' => [ + 'DigitalZoomRatio' => '0/10', + 'Flash' => '9' + ] + ], + $reader->getResults(), + 'Check that doctype is detected in fragmented XML' + ); + } +} diff --git a/tests/phpunit/includes/libs/xmp/XMPValidateTest.php b/tests/phpunit/includes/libs/xmp/XMPValidateTest.php new file mode 100644 index 0000000000..7f7ea930e2 --- /dev/null +++ b/tests/phpunit/includes/libs/xmp/XMPValidateTest.php @@ -0,0 +1,53 @@ +validateDate( [], $value, true ); + $this->assertEquals( $expected, $value ); + } + + public static function provideDates() { + /* For reference valid date formats are: + * YYYY + * YYYY-MM + * YYYY-MM-DD + * YYYY-MM-DDThh:mmTZD + * YYYY-MM-DDThh:mm:ssTZD + * YYYY-MM-DDThh:mm:ss.sTZD + * (Time zone is optional) + */ + return [ + [ '1992', '1992' ], + [ '1992-04', '1992:04' ], + [ '1992-02-01', '1992:02:01' ], + [ '2011-09-29', '2011:09:29' ], + [ '1982-12-15T20:12', '1982:12:15 20:12' ], + [ '1982-12-15T20:12Z', '1982:12:15 20:12' ], + [ '1982-12-15T20:12+02:30', '1982:12:15 22:42' ], + [ '1982-12-15T01:12-02:30', '1982:12:14 22:42' ], + [ '1982-12-15T20:12:11', '1982:12:15 20:12:11' ], + [ '1982-12-15T20:12:11Z', '1982:12:15 20:12:11' ], + [ '1982-12-15T20:12:11+01:10', '1982:12:15 21:22:11' ], + [ '2045-12-15T20:12:11', '2045:12:15 20:12:11' ], + [ '1867-06-01T15:00:00', '1867:06:01 15:00:00' ], + /* some invalid ones */ + [ '2001--12', null ], + [ '2001-5-12', null ], + [ '2001-5-12TZ', null ], + [ '2001-05-12T15', null ], + [ '2001-12T15:13', null ], + ]; + } +} diff --git a/tests/phpunit/includes/linker/LinkRendererFactoryTest.php b/tests/phpunit/includes/linker/LinkRendererFactoryTest.php new file mode 100644 index 0000000000..bf12f80f0a --- /dev/null +++ b/tests/phpunit/includes/linker/LinkRendererFactoryTest.php @@ -0,0 +1,81 @@ +titleFormatter = MediaWikiServices::getInstance()->getTitleFormatter(); + $this->linkCache = MediaWikiServices::getInstance()->getLinkCache(); + } + + public static function provideCreateFromLegacyOptions() { + return [ + [ + [ 'forcearticlepath' ], + 'getForceArticlePath', + true + ], + [ + [ 'http' ], + 'getExpandURLs', + PROTO_HTTP + ], + [ + [ 'https' ], + 'getExpandURLs', + PROTO_HTTPS + ], + [ + [ 'stubThreshold' => 150 ], + 'getStubThreshold', + 150 + ], + ]; + } + + /** + * @dataProvider provideCreateFromLegacyOptions + */ + public function testCreateFromLegacyOptions( $options, $func, $val ) { + $factory = new LinkRendererFactory( $this->titleFormatter, $this->linkCache ); + $linkRenderer = $factory->createFromLegacyOptions( + $options + ); + $this->assertInstanceOf( LinkRenderer::class, $linkRenderer ); + $this->assertEquals( $val, $linkRenderer->$func(), $func ); + } + + public function testCreate() { + $factory = new LinkRendererFactory( $this->titleFormatter, $this->linkCache ); + $this->assertInstanceOf( LinkRenderer::class, $factory->create() ); + } + + public function testCreateForUser() { + /** @var PHPUnit_Framework_MockObject_MockObject|User $user */ + $user = $this->getMock( User::class, [ 'getStubThreshold' ] ); + $user->expects( $this->once() ) + ->method( 'getStubThreshold' ) + ->willReturn( 15 ); + $factory = new LinkRendererFactory( $this->titleFormatter, $this->linkCache ); + $linkRenderer = $factory->createForUser( $user ); + $this->assertInstanceOf( LinkRenderer::class, $linkRenderer ); + $this->assertEquals( 15, $linkRenderer->getStubThreshold() ); + } +} diff --git a/tests/phpunit/includes/linker/LinkRendererTest.php b/tests/phpunit/includes/linker/LinkRendererTest.php new file mode 100644 index 0000000000..6d096c208b --- /dev/null +++ b/tests/phpunit/includes/linker/LinkRendererTest.php @@ -0,0 +1,189 @@ +setMwGlobals( [ + 'wgArticlePath' => '/wiki/$1', + 'wgServer' => '//example.org', + 'wgCanonicalServer' => 'http://example.org', + 'wgScriptPath' => '/w', + 'wgScript' => '/w/index.php', + ] ); + $this->factory = MediaWikiServices::getInstance()->getLinkRendererFactory(); + } + + public function testMergeAttribs() { + $target = new TitleValue( NS_SPECIAL, 'Blankpage' ); + $linkRenderer = $this->factory->create(); + $link = $linkRenderer->makeBrokenLink( $target, null, [ + // Appended to class + 'class' => 'foobar', + // Suppresses href attribute + 'href' => false, + // Extra attribute + 'bar' => 'baz' + ] ); + $this->assertEquals( + '' + . 'Special:BlankPage', + $link + ); + } + + public function testMakeKnownLink() { + $target = new TitleValue( NS_MAIN, 'Foobar' ); + $linkRenderer = $this->factory->create(); + + // Query added + $this->assertEquals( + 'Foobar', + $linkRenderer->makeKnownLink( $target, null, [], [ 'foo' => 'bar' ] ) + ); + + // forcearticlepath + $linkRenderer->setForceArticlePath( true ); + $this->assertEquals( + 'Foobar', + $linkRenderer->makeKnownLink( $target, null, [], [ 'foo' => 'bar' ] ) + ); + + // expand = HTTPS + $linkRenderer->setForceArticlePath( false ); + $linkRenderer->setExpandURLs( PROTO_HTTPS ); + $this->assertEquals( + 'Foobar', + $linkRenderer->makeKnownLink( $target ) + ); + } + + public function testMakeBrokenLink() { + $target = new TitleValue( NS_MAIN, 'Foobar' ); + $special = new TitleValue( NS_SPECIAL, 'Foobar' ); + $linkRenderer = $this->factory->create(); + + // action=edit&redlink=1 added + $this->assertEquals( + 'Foobar', + $linkRenderer->makeBrokenLink( $target ) + ); + + // action=edit&redlink=1 not added due to action query parameter + $this->assertEquals( + 'Foobar', + $linkRenderer->makeBrokenLink( $target, null, [], [ 'action' => 'foobar' ] ) + ); + + // action=edit&redlink=1 not added due to NS_SPECIAL + $this->assertEquals( + 'Special:Foobar', + $linkRenderer->makeBrokenLink( $special ) + ); + + // fragment stripped + $this->assertEquals( + 'Foobar', + $linkRenderer->makeBrokenLink( $target->createFragmentTarget( 'foobar' ) ) + ); + } + + public function testMakeLink() { + $linkRenderer = $this->factory->create(); + $foobar = new TitleValue( NS_SPECIAL, 'Foobar' ); + $blankpage = new TitleValue( NS_SPECIAL, 'Blankpage' ); + $this->assertEquals( + 'foo', + $linkRenderer->makeLink( $foobar, 'foo' ) + ); + + $this->assertEquals( + 'blank', + $linkRenderer->makeLink( $blankpage, 'blank' ) + ); + + $this->assertEquals( + '<script>evil()</script>', + $linkRenderer->makeLink( $foobar, '' ) + ); + + $this->assertEquals( + '', + $linkRenderer->makeLink( $foobar, new HtmlArmor( '' ) ) + ); + } + + public function testGetLinkClasses() { + $wanCache = ObjectCache::getMainWANInstance(); + $titleFormatter = MediaWikiServices::getInstance()->getTitleFormatter(); + $linkCache = new LinkCache( $titleFormatter, $wanCache ); + $foobarTitle = new TitleValue( NS_MAIN, 'FooBar' ); + $redirectTitle = new TitleValue( NS_MAIN, 'Redirect' ); + $userTitle = new TitleValue( NS_USER, 'Someuser' ); + $linkCache->addGoodLinkObj( + 1, // id + $foobarTitle, + 10, // len + 0 // redir + ); + $linkCache->addGoodLinkObj( + 2, // id + $redirectTitle, + 10, // len + 1 // redir + ); + + $linkCache->addGoodLinkObj( + 3, // id + $userTitle, + 10, // len + 0 // redir + ); + + $linkRenderer = new LinkRenderer( $titleFormatter, $linkCache ); + $linkRenderer->setStubThreshold( 0 ); + $this->assertEquals( + '', + $linkRenderer->getLinkClasses( $foobarTitle ) + ); + + $linkRenderer->setStubThreshold( 20 ); + $this->assertEquals( + 'stub', + $linkRenderer->getLinkClasses( $foobarTitle ) + ); + + $linkRenderer->setStubThreshold( 0 ); + $this->assertEquals( + 'mw-redirect', + $linkRenderer->getLinkClasses( $redirectTitle ) + ); + + $linkRenderer->setStubThreshold( 20 ); + $this->assertEquals( + '', + $linkRenderer->getLinkClasses( $userTitle ) + ); + } + +} diff --git a/tests/phpunit/includes/logging/LogFormatterTestCase.php b/tests/phpunit/includes/logging/LogFormatterTestCase.php index b09e5b1847..c289839b49 100644 --- a/tests/phpunit/includes/logging/LogFormatterTestCase.php +++ b/tests/phpunit/includes/logging/LogFormatterTestCase.php @@ -50,7 +50,7 @@ abstract class LogFormatterTestCase extends MediaWikiLangTestCase { private static function removeSomeHtml( $html ) { $html = str_replace( '"', '"', $html ); $html = preg_replace( '/\xE2\x80[\x8E\x8F]/', '', $html ); // Strip lrm/rlm - return trim( preg_replace( '/<(a|span)[^>]*>([^<]*)<\/\1>/', '$2', $html ) ); + return trim( strip_tags( $html ) ); } private static function removeApiMetaData( $val ) { diff --git a/tests/phpunit/includes/media/ExifBitmapTest.php b/tests/phpunit/includes/media/ExifBitmapTest.php index f70b42de42..47ed67bdb1 100644 --- a/tests/phpunit/includes/media/ExifBitmapTest.php +++ b/tests/phpunit/includes/media/ExifBitmapTest.php @@ -17,7 +17,6 @@ class ExifBitmapTest extends MediaWikiMediaTestCase { $this->setMwGlobals( 'wgShowEXIF', true ); $this->handler = new ExifBitmapHandler; - } /** diff --git a/tests/phpunit/includes/media/MediaWikiMediaTestCase.php b/tests/phpunit/includes/media/MediaWikiMediaTestCase.php index 5042121ccc..e854ab57f7 100644 --- a/tests/phpunit/includes/media/MediaWikiMediaTestCase.php +++ b/tests/phpunit/includes/media/MediaWikiMediaTestCase.php @@ -26,7 +26,8 @@ abstract class MediaWikiMediaTestCase extends MediaWikiTestCase { $this->backend = new FSFileBackend( [ 'name' => 'localtesting', 'wikiId' => wfWikiID(), - 'containerPaths' => $containers + 'containerPaths' => $containers, + 'tmpDirectory' => $this->getNewTempDirectory() ] ); $this->repo = new FSRepo( $this->getRepoOptions() ); } diff --git a/tests/phpunit/includes/media/XMPTest.php b/tests/phpunit/includes/media/XMPTest.php deleted file mode 100644 index bffe415c5d..0000000000 --- a/tests/phpunit/includes/media/XMPTest.php +++ /dev/null @@ -1,223 +0,0 @@ -checkPHPExtension( 'exif' ); # Requires libxml to do XMP parsing - } - - /** - * Put XMP in, compare what comes out... - * - * @param string $xmp The actual xml data. - * @param array $expected Expected result of parsing the xmp. - * @param string $info Short sentence on what's being tested. - * - * @throws Exception - * @dataProvider provideXMPParse - * - * @covers XMPReader::parse - */ - public function testXMPParse( $xmp, $expected, $info ) { - if ( !is_string( $xmp ) || !is_array( $expected ) ) { - throw new Exception( "Invalid data provided to " . __METHOD__ ); - } - $reader = new XMPReader; - $reader->parse( $xmp ); - $this->assertEquals( $expected, $reader->getResults(), $info, 0.0000000001 ); - } - - public static function provideXMPParse() { - $xmpPath = __DIR__ . '/../../data/xmp/'; - $data = []; - - // $xmpFiles format: array of arrays with first arg file base name, - // with the actual file having .xmp on the end for the xmp - // and .result.php on the end for a php file containing the result - // array. Second argument is some info on what's being tested. - $xmpFiles = [ - [ '1', 'parseType=Resource test' ], - [ '2', 'Structure with mixed attribute and element props' ], - [ '3', 'Extra qualifiers (that should be ignored)' ], - [ '3-invalid', 'Test ignoring qualifiers that look like normal props' ], - [ '4', 'Flash as qualifier' ], - [ '5', 'Flash as qualifier 2' ], - [ '6', 'Multiple rdf:Description' ], - [ '7', 'Generic test of several property types' ], - [ 'flash', 'Test of Flash property' ], - [ 'invalid-child-not-struct', 'Test child props not in struct or ignored' ], - [ 'no-recognized-props', 'Test namespace and no recognized props' ], - [ 'no-namespace', 'Test non-namespaced attributes are ignored' ], - [ 'bag-for-seq', "Allow bag's instead of seq's. (bug 27105)" ], - [ 'utf16BE', 'UTF-16BE encoding' ], - [ 'utf16LE', 'UTF-16LE encoding' ], - [ 'utf32BE', 'UTF-32BE encoding' ], - [ 'utf32LE', 'UTF-32LE encoding' ], - [ 'xmpExt', 'Extended XMP missing second part' ], - [ 'gps', 'Handling of exif GPS parameters in XMP' ], - ]; - - $xmpFiles[] = [ 'doctype-included', 'XMP includes doctype' ]; - - foreach ( $xmpFiles as $file ) { - $xmp = file_get_contents( $xmpPath . $file[0] . '.xmp' ); - // I'm not sure if this is the best way to handle getting the - // result array, but it seems kind of big to put directly in the test - // file. - $result = null; - include $xmpPath . $file[0] . '.result.php'; - $data[] = [ $xmp, $result, '[' . $file[0] . '.xmp] ' . $file[1] ]; - } - - return $data; - } - - /** Test ExtendedXMP block support. (Used when the XMP has to be split - * over multiple jpeg segments, due to 64k size limit on jpeg segments. - * - * @todo This is based on what the standard says. Need to find a real - * world example file to double check the support for this is right. - * - * @covers XMPReader::parseExtended - */ - public function testExtendedXMP() { - $xmpPath = __DIR__ . '/../../data/xmp/'; - $standardXMP = file_get_contents( $xmpPath . 'xmpExt.xmp' ); - $extendedXMP = file_get_contents( $xmpPath . 'xmpExt2.xmp' ); - - $md5sum = '28C74E0AC2D796886759006FBE2E57B7'; // of xmpExt2.xmp - $length = pack( 'N', strlen( $extendedXMP ) ); - $offset = pack( 'N', 0 ); - $extendedPacket = $md5sum . $length . $offset . $extendedXMP; - - $reader = new XMPReader(); - $reader->parse( $standardXMP ); - $reader->parseExtended( $extendedPacket ); - $actual = $reader->getResults(); - - $expected = [ - 'xmp-exif' => [ - 'DigitalZoomRatio' => '0/10', - 'Flash' => 9, - 'FNumber' => '2/10', - ] - ]; - - $this->assertEquals( $expected, $actual ); - } - - /** - * This test has an extended XMP block with a wrong guid (md5sum) - * and thus should only return the StandardXMP, not the ExtendedXMP. - * - * @covers XMPReader::parseExtended - */ - public function testExtendedXMPWithWrongGUID() { - $xmpPath = __DIR__ . '/../../data/xmp/'; - $standardXMP = file_get_contents( $xmpPath . 'xmpExt.xmp' ); - $extendedXMP = file_get_contents( $xmpPath . 'xmpExt2.xmp' ); - - $md5sum = '28C74E0AC2D796886759006FBE2E57B9'; // Note last digit. - $length = pack( 'N', strlen( $extendedXMP ) ); - $offset = pack( 'N', 0 ); - $extendedPacket = $md5sum . $length . $offset . $extendedXMP; - - $reader = new XMPReader(); - $reader->parse( $standardXMP ); - $reader->parseExtended( $extendedPacket ); - $actual = $reader->getResults(); - - $expected = [ - 'xmp-exif' => [ - 'DigitalZoomRatio' => '0/10', - 'Flash' => 9, - ] - ]; - - $this->assertEquals( $expected, $actual ); - } - - /** - * Have a high offset to simulate a missing packet, - * which should cause it to ignore the ExtendedXMP packet. - * - * @covers XMPReader::parseExtended - */ - public function testExtendedXMPMissingPacket() { - $xmpPath = __DIR__ . '/../../data/xmp/'; - $standardXMP = file_get_contents( $xmpPath . 'xmpExt.xmp' ); - $extendedXMP = file_get_contents( $xmpPath . 'xmpExt2.xmp' ); - - $md5sum = '28C74E0AC2D796886759006FBE2E57B7'; // of xmpExt2.xmp - $length = pack( 'N', strlen( $extendedXMP ) ); - $offset = pack( 'N', 2048 ); - $extendedPacket = $md5sum . $length . $offset . $extendedXMP; - - $reader = new XMPReader(); - $reader->parse( $standardXMP ); - $reader->parseExtended( $extendedPacket ); - $actual = $reader->getResults(); - - $expected = [ - 'xmp-exif' => [ - 'DigitalZoomRatio' => '0/10', - 'Flash' => 9, - ] - ]; - - $this->assertEquals( $expected, $actual ); - } - - /** - * Test for multi-section, hostile XML - * @covers XMPReader::checkParseSafety - */ - public function testCheckParseSafety() { - - // Test for detection - $xmpPath = __DIR__ . '/../../data/xmp/'; - $file = fopen( $xmpPath . 'doctype-included.xmp', 'rb' ); - $valid = false; - $reader = new XMPReader(); - do { - $chunk = fread( $file, 10 ); - $valid = $reader->parse( $chunk, feof( $file ) ); - } while ( !feof( $file ) ); - $this->assertFalse( $valid, 'Check that doctype is detected in fragmented XML' ); - $this->assertEquals( - [], - $reader->getResults(), - 'Check that doctype is detected in fragmented XML' - ); - fclose( $file ); - unset( $reader ); - - // Test for false positives - $file = fopen( $xmpPath . 'doctype-not-included.xmp', 'rb' ); - $valid = false; - $reader = new XMPReader(); - do { - $chunk = fread( $file, 10 ); - $valid = $reader->parse( $chunk, feof( $file ) ); - } while ( !feof( $file ) ); - $this->assertTrue( - $valid, - 'Check for false-positive detecting doctype in fragmented XML' - ); - $this->assertEquals( - [ - 'xmp-exif' => [ - 'DigitalZoomRatio' => '0/10', - 'Flash' => '9' - ] - ], - $reader->getResults(), - 'Check that doctype is detected in fragmented XML' - ); - } -} diff --git a/tests/phpunit/includes/media/XMPValidateTest.php b/tests/phpunit/includes/media/XMPValidateTest.php deleted file mode 100644 index 6a006295df..0000000000 --- a/tests/phpunit/includes/media/XMPValidateTest.php +++ /dev/null @@ -1,53 +0,0 @@ -validateDate( [], $value, true ); - $this->assertEquals( $expected, $value ); - } - - public static function provideDates() { - /* For reference valid date formats are: - * YYYY - * YYYY-MM - * YYYY-MM-DD - * YYYY-MM-DDThh:mmTZD - * YYYY-MM-DDThh:mm:ssTZD - * YYYY-MM-DDThh:mm:ss.sTZD - * (Time zone is optional) - */ - return [ - [ '1992', '1992' ], - [ '1992-04', '1992:04' ], - [ '1992-02-01', '1992:02:01' ], - [ '2011-09-29', '2011:09:29' ], - [ '1982-12-15T20:12', '1982:12:15 20:12' ], - [ '1982-12-15T20:12Z', '1982:12:15 20:12' ], - [ '1982-12-15T20:12+02:30', '1982:12:15 22:42' ], - [ '1982-12-15T01:12-02:30', '1982:12:14 22:42' ], - [ '1982-12-15T20:12:11', '1982:12:15 20:12:11' ], - [ '1982-12-15T20:12:11Z', '1982:12:15 20:12:11' ], - [ '1982-12-15T20:12:11+01:10', '1982:12:15 21:22:11' ], - [ '2045-12-15T20:12:11', '2045:12:15 20:12:11' ], - [ '1867-06-01T15:00:00', '1867:06:01 15:00:00' ], - /* some invalid ones */ - [ '2001--12', null ], - [ '2001-5-12', null ], - [ '2001-5-12TZ', null ], - [ '2001-05-12T15', null ], - [ '2001-12T15:13', null ], - ]; - } -} diff --git a/tests/phpunit/includes/objectcache/RESTBagOStuffTest.php b/tests/phpunit/includes/objectcache/RESTBagOStuffTest.php new file mode 100644 index 0000000000..ebeb1092a6 --- /dev/null +++ b/tests/phpunit/includes/objectcache/RESTBagOStuffTest.php @@ -0,0 +1,88 @@ +client = + $this->getMockBuilder( 'MultiHttpClient' ) + ->setConstructorArgs( [ [] ] ) + ->setMethods( [ 'run' ] ) + ->getMock(); + $this->bag = new RESTBagOStuff( [ 'client' => $this->client, 'url' => 'http://test/rest/' ] ); + } + + public function testGet() { + $this->client->expects( $this->once() )->method( 'run' )->with( [ + 'method' => 'GET', + 'url' => 'http://test/rest/42xyz42' + // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) + ] )->willReturn( [ 200, 'OK', [], 's:8:"somedata";', 0 ] ); + $result = $this->bag->get( '42xyz42' ); + $this->assertEquals( 'somedata', $result ); + } + + public function testGetNotExist() { + $this->client->expects( $this->once() )->method( 'run' )->with( [ + 'method' => 'GET', + 'url' => 'http://test/rest/42xyz42' + // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) + ] )->willReturn( [ 404, 'Not found', [], 'Nothing to see here', 0 ] ); + $result = $this->bag->get( '42xyz42' ); + $this->assertFalse( $result ); + } + + public function testGetBadClient() { + $this->client->expects( $this->once() )->method( 'run' )->with( [ + 'method' => 'GET', + 'url' => 'http://test/rest/42xyz42' + // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) + ] )->willReturn( [ 0, '', [], '', 'cURL has failed you today' ] ); + $result = $this->bag->get( '42xyz42' ); + $this->assertFalse( $result ); + $this->assertEquals( BagOStuff::ERR_UNREACHABLE, $this->bag->getLastError() ); + } + + public function testGetBadServer() { + $this->client->expects( $this->once() )->method( 'run' )->with( [ + 'method' => 'GET', + 'url' => 'http://test/rest/42xyz42' + // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) + ] )->willReturn( [ 500, 'Too busy', [], 'Server is too busy', '' ] ); + $result = $this->bag->get( '42xyz42' ); + $this->assertFalse( $result ); + $this->assertEquals( BagOStuff::ERR_UNEXPECTED, $this->bag->getLastError() ); + } + + public function testPut() { + $this->client->expects( $this->once() )->method( 'run' )->with( [ + 'method' => 'PUT', + 'url' => 'http://test/rest/42xyz42', + 'body' => 's:8:"postdata";' + // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) + ] )->willReturn( [ 200, 'OK', [], 'Done', 0 ] ); + $result = $this->bag->set( '42xyz42', 'postdata' ); + $this->assertTrue( $result ); + } + + public function testDelete() { + $this->client->expects( $this->once() )->method( 'run' )->with( [ + 'method' => 'DELETE', + 'url' => 'http://test/rest/42xyz42', + // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) + ] )->willReturn( [ 200, 'OK', [], 'Done', 0 ] ); + $result = $this->bag->delete( '42xyz42' ); + $this->assertTrue( $result ); + } +} diff --git a/tests/phpunit/includes/objectcache/RedisBagOStuffTest.php b/tests/phpunit/includes/objectcache/RedisBagOStuffTest.php new file mode 100644 index 0000000000..705a34a68d --- /dev/null +++ b/tests/phpunit/includes/objectcache/RedisBagOStuffTest.php @@ -0,0 +1,104 @@ +getMockBuilder( 'RedisBagOStuff' ) + ->disableOriginalConstructor() + ->getMock(); + $this->cache = TestingAccessWrapper::newFromObject( $cache ); + } + + /** + * @covers RedisBagOStuff::unserialize + * @dataProvider unserializeProvider + */ + public function testUnserialize( $expected, $input, $message ) { + $actual = $this->cache->unserialize( $input ); + $this->assertSame( $expected, $actual, $message ); + } + + public function unserializeProvider() { + return [ + [ + -1, + '-1', + 'String representation of \'-1\'', + ], + [ + 0, + '0', + 'String representation of \'0\'', + ], + [ + 1, + '1', + 'String representation of \'1\'', + ], + [ + -1.0, + 'd:-1;', + 'Serialized negative double', + ], + [ + 'foo', + 's:3:"foo";', + 'Serialized string', + ] + ]; + } + + /** + * @covers RedisBagOStuff::serialize + * @dataProvider serializeProvider + */ + public function testSerialize( $expected, $input, $message ) { + $actual = $this->cache->serialize( $input ); + $this->assertSame( $expected, $actual, $message ); + } + + public function serializeProvider() { + return [ + [ + -1, + -1, + '-1 as integer', + ], + [ + 0, + 0, + '0 as integer', + ], + [ + 1, + 1, + '1 as integer', + ], + [ + 'd:-1;', + -1.0, + 'Negative double', + ], + [ + 's:3:"2.1";', + '2.1', + 'Decimal string', + ], + [ + 's:1:"1";', + '1', + 'String representation of 1', + ], + [ + 's:3:"foo";', + 'foo', + 'String', + ], + ]; + } +} diff --git a/tests/phpunit/includes/page/ArticleTest.php b/tests/phpunit/includes/page/ArticleTest.php index a96a2960fb..7d0813d141 100644 --- a/tests/phpunit/includes/page/ArticleTest.php +++ b/tests/phpunit/includes/page/ArticleTest.php @@ -63,13 +63,9 @@ class ArticleTest extends MediaWikiTestCase { * @covers Article::onArticleCreate * @covers Article::onArticleDelete * @covers Article::onArticleEdit - * @covers Article::getAutosummary */ public function testStaticFunctions() { $this->hideDeprecated( 'Article::selectFields' ); - $this->hideDeprecated( 'Article::getAutosummary' ); - $this->hideDeprecated( 'WikiPage::getAutosummary' ); - $this->hideDeprecated( 'CategoryPage::getAutosummary' ); // Inherited from Article $this->assertEquals( WikiPage::selectFields(), Article::selectFields(), "Article static functions" ); @@ -79,7 +75,5 @@ class ArticleTest extends MediaWikiTestCase { "Article static functions" ); $this->assertEquals( true, is_callable( "ImagePage::onArticleEdit" ), "Article static functions" ); - $this->assertTrue( is_string( CategoryPage::getAutosummary( '', '', 0 ) ), - "Article static functions" ); } } diff --git a/tests/phpunit/includes/page/WikiCategoryPageTest.php b/tests/phpunit/includes/page/WikiCategoryPageTest.php index 9f4e1fabbb..5f1bf0ca79 100644 --- a/tests/phpunit/includes/page/WikiCategoryPageTest.php +++ b/tests/phpunit/includes/page/WikiCategoryPageTest.php @@ -1,5 +1,7 @@ newPage( "WikiPageTest_testDoEditContent" ); @@ -154,6 +157,7 @@ class WikiPageTest extends MediaWikiLangTestCase { /** * @covers WikiPage::doEdit + * @deprecated since 1.21. Should be removed when WikiPage::doEdit() gets removed */ public function testDoEdit() { $this->hideDeprecated( "WikiPage::doEdit" ); @@ -212,30 +216,6 @@ class WikiPageTest extends MediaWikiLangTestCase { $this->assertEquals( 2, $n, 'pagelinks should contain two links from the page' ); } - /** - * @covers WikiPage::doQuickEditContent - */ - public function testDoQuickEditContent() { - global $wgUser; - - $page = $this->createPage( - "WikiPageTest_testDoQuickEditContent", - "original text", - CONTENT_MODEL_WIKITEXT - ); - - $content = ContentHandler::makeContent( - "quick text", - $page->getTitle(), - CONTENT_MODEL_WIKITEXT - ); - $page->doQuickEditContent( $content, $wgUser, "testing q" ); - - # --------------------- - $page = new WikiPage( $page->getTitle() ); - $this->assertTrue( $content->equals( $page->getContent() ) ); - } - /** * @covers WikiPage::doDeleteArticle */ @@ -1054,63 +1034,6 @@ more stuff $this->assertEquals( "one", $page->getContent()->getNativeData() ); } - public static function provideGetAutosummary() { - return [ - [ - 'Hello there, world!', - '#REDIRECT [[Foo]]', - 0, - '/^Redirected page .*Foo/' - ], - - [ - null, - 'Hello world!', - EDIT_NEW, - '/^Created page .*Hello/' - ], - - [ - 'Hello there, world!', - '', - 0, - '/^Blanked/' - ], - - [ - 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy - eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam - voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet - clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', - 'Hello world!', - 0, - '/^Replaced .*Hello/' - ], - - [ - 'foo', - 'bar', - 0, - '/^$/' - ], - ]; - } - - /** - * @dataProvider provideGetAutoSummary - * @covers WikiPage::getAutosummary - */ - public function testGetAutosummary( $old, $new, $flags, $expected ) { - $this->hideDeprecated( "WikiPage::getAutosummary" ); - - $page = $this->newPage( "WikiPageTest_testGetAutosummary" ); - - $summary = $page->getAutosummary( $old, $new, $flags ); - - $this->assertTrue( (bool)preg_match( $expected, $summary ), - "Autosummary didn't match expected pattern $expected: $summary" ); - } - public static function provideGetAutoDeleteReason() { return [ [ diff --git a/tests/phpunit/includes/pager/ReverseChronologicalPagerTest.php b/tests/phpunit/includes/pager/ReverseChronologicalPagerTest.php new file mode 100644 index 0000000000..fc5d660274 --- /dev/null +++ b/tests/phpunit/includes/pager/ReverseChronologicalPagerTest.php @@ -0,0 +1,69 @@ + + */ +class ReverseChronologicalPagerTest extends MediaWikiLangTestCase { + + /** + * @covers ReverseChronologicalPager::getDateCond + */ + public function testGetDateCond() { + $pager = $this->getMockForAbstractClass( 'ReverseChronologicalPager' ); + $timestamp = MWTimestamp::getInstance(); + $db = wfGetDB( DB_MASTER ); + + $currYear = $timestamp->format( 'Y' ); + $currMonth = $timestamp->format( 'n' ); + + // Test that getDateCond sets and returns mOffset + $this->assertEquals( $pager->getDateCond( 2006, 6 ), $pager->mOffset ); + + // Test year and month + $pager->getDateCond( 2006, 6 ); + $this->assertEquals( $pager->mOffset, $db->timestamp( '20060701000000' ) ); + + // Test year, month, and day + $pager->getDateCond( 2006, 6, 5 ); + $this->assertEquals( $pager->mOffset, $db->timestamp( '20060606000000' ) ); + + // Test month overflow into the next year + $pager->getDateCond( 2006, 12 ); + $this->assertEquals( $pager->mOffset, $db->timestamp( '20070101000000' ) ); + + // Test day overflow to the next month + $pager->getDateCond( 2006, 6, 30 ); + $this->assertEquals( $pager->mOffset, $db->timestamp( '20060701000000' ) ); + + // Test invalid month (should use end of year) + $pager->getDateCond( 2006, -1 ); + $this->assertEquals( $pager->mOffset, $db->timestamp( '20070101000000' ) ); + + // Test invalid day (should use end of month) + $pager->getDateCond( 2006, 6, 1337 ); + $this->assertEquals( $pager->mOffset, $db->timestamp( '20060701000000' ) ); + + // Test last day of year + $pager->getDateCond( 2006, 12, 31 ); + $this->assertEquals( $pager->mOffset, $db->timestamp( '20070101000000' ) ); + + // Test invalid day that overflows to next year + $pager->getDateCond( 2006, 12, 32 ); + $this->assertEquals( $pager->mOffset, $db->timestamp( '20070101000000' ) ); + + // Test month past current month (should use previous year) + if ( $currMonth < 5 ) { + $pager->getDateCond( -1, 5 ); + $this->assertEquals( $pager->mOffset, $db->timestamp( $currYear - 1 . '0601000000' ) ); + } + if ( $currMonth < 12 ) { + $pager->getDateCond( -1, 12 ); + $this->assertEquals( $pager->mOffset, $db->timestamp( $currYear . '0101000000' ) ); + } + } +} + diff --git a/tests/phpunit/includes/parser/MediaWikiParserTest.php b/tests/phpunit/includes/parser/MediaWikiParserTest.php deleted file mode 100644 index 173447fcf7..0000000000 --- a/tests/phpunit/includes/parser/MediaWikiParserTest.php +++ /dev/null @@ -1,138 +0,0 @@ - "\\'", '\\' => '\\\\' ] ); - $parserTestClassName = ucfirst( $testsName ); - - // Official spec for class names: http://php.net/manual/en/language.oop5.basic.php - // Prepend 'ParserTest_' to be paranoid about it not starting with a number - $parserTestClassName = 'ParserTest_' . - preg_replace( '/[^a-zA-Z0-9_\x7f-\xff]/', '_', $parserTestClassName ); - - if ( isset( $testList[$parserTestClassName] ) ) { - // If a conflict happens, gives a very unclear fatal. - // So as a last ditch effort to prevent that eventuality, if there - // is a conflict, append a number. - $counter++; - $parserTestClassName .= $counter; - } - $testList[$parserTestClassName] = true; - $parserTestClassDefinition = <<addTestSuite( $parserTestClassName ); - } - return $suite; - } - - /** - * Write $msg under log group 'tests-parser' - * @param string $msg Message to log - */ - protected static function debug( $msg ) { - return wfDebugLog( 'tests-parser', wfGetCaller() . ' ' . $msg ); - } -} diff --git a/tests/phpunit/includes/parser/NewParserTest.php b/tests/phpunit/includes/parser/NewParserTest.php deleted file mode 100644 index 22bb23784f..0000000000 --- a/tests/phpunit/includes/parser/NewParserTest.php +++ /dev/null @@ -1,1125 +0,0 @@ -getCliArg( 'regex' ) ) { - $this->regex = $this->getCliArg( 'regex' ); - } else { - # Matches anything - $this->regex = ''; - } - - $this->keepUploads = $this->getCliArg( 'keep-uploads' ); - - $tmpGlobals = []; - - $tmpGlobals['wgLanguageCode'] = 'en'; - $tmpGlobals['wgContLang'] = Language::factory( 'en' ); - $tmpGlobals['wgSitename'] = 'MediaWiki'; - $tmpGlobals['wgServer'] = 'http://example.org'; - $tmpGlobals['wgServerName'] = 'example.org'; - $tmpGlobals['wgScriptPath'] = ''; - $tmpGlobals['wgScript'] = '/index.php'; - $tmpGlobals['wgResourceBasePath'] = ''; - $tmpGlobals['wgStylePath'] = '/skins'; - $tmpGlobals['wgExtensionAssetsPath'] = '/extensions'; - $tmpGlobals['wgArticlePath'] = '/wiki/$1'; - $tmpGlobals['wgActionPaths'] = []; - $tmpGlobals['wgVariantArticlePath'] = false; - $tmpGlobals['wgEnableUploads'] = true; - $tmpGlobals['wgUploadNavigationUrl'] = false; - $tmpGlobals['wgThumbnailScriptPath'] = false; - $tmpGlobals['wgLocalFileRepo'] = [ - 'class' => 'LocalRepo', - 'name' => 'local', - 'url' => 'http://example.com/images', - 'hashLevels' => 2, - 'transformVia404' => false, - 'backend' => 'local-backend' - ]; - $tmpGlobals['wgForeignFileRepos'] = []; - $tmpGlobals['wgDefaultExternalStore'] = []; - $tmpGlobals['wgParserCacheType'] = CACHE_NONE; - $tmpGlobals['wgCapitalLinks'] = true; - $tmpGlobals['wgNoFollowLinks'] = true; - $tmpGlobals['wgNoFollowDomainExceptions'] = []; - $tmpGlobals['wgExternalLinkTarget'] = false; - $tmpGlobals['wgThumbnailScriptPath'] = false; - $tmpGlobals['wgUseImageResize'] = true; - $tmpGlobals['wgAllowExternalImages'] = true; - $tmpGlobals['wgRawHtml'] = false; - $tmpGlobals['wgWellFormedXml'] = true; - $tmpGlobals['wgExperimentalHtmlIds'] = false; - $tmpGlobals['wgAdaptiveMessageCache'] = true; - $tmpGlobals['wgUseDatabaseMessages'] = true; - $tmpGlobals['wgLocaltimezone'] = 'UTC'; - $tmpGlobals['wgGroupPermissions'] = [ - '*' => [ - 'createaccount' => true, - 'read' => true, - 'edit' => true, - 'createpage' => true, - 'createtalk' => true, - ] ]; - $tmpGlobals['wgNamespaceProtection'] = [ NS_MEDIAWIKI => 'editinterface' ]; - - $tmpGlobals['wgParser'] = new StubObject( - 'wgParser', $GLOBALS['wgParserConf']['class'], - [ $GLOBALS['wgParserConf'] ] ); - - $tmpGlobals['wgFileExtensions'][] = 'svg'; - $tmpGlobals['wgSVGConverter'] = 'rsvg'; - $tmpGlobals['wgSVGConverters']['rsvg'] = - '$path/rsvg-convert -w $width -h $height -o $output $input'; - - if ( $GLOBALS['wgStyleDirectory'] === false ) { - $tmpGlobals['wgStyleDirectory'] = "$IP/skins"; - } - - # Replace all media handlers with a mock. We do not need to generate - # actual thumbnails to do parser testing, we only care about receiving - # a ThumbnailImage properly initialized. - global $wgMediaHandlers; - foreach ( $wgMediaHandlers as $type => $handler ) { - $tmpGlobals['wgMediaHandlers'][$type] = 'MockBitmapHandler'; - } - // Vector images have to be handled slightly differently - $tmpGlobals['wgMediaHandlers']['image/svg+xml'] = 'MockSvgHandler'; - - // DjVu images have to be handled slightly differently - $tmpGlobals['wgMediaHandlers']['image/vnd.djvu'] = 'MockDjVuHandler'; - - // Ogg video/audio increasingly more differently - $tmpGlobals['wgMediaHandlers']['application/ogg'] = 'MockOggHandler'; - - $tmpHooks = $wgHooks; - $tmpHooks['ParserTestParser'][] = 'ParserTestParserHook::setup'; - $tmpHooks['ParserGetVariableValueTs'][] = 'ParserTest::getFakeTimestamp'; - $tmpGlobals['wgHooks'] = $tmpHooks; - # add a namespace shadowing a interwiki link, to test - # proper precedence when resolving links. (bug 51680) - $tmpGlobals['wgExtraNamespaces'] = [ 100 => 'MemoryAlpha' ]; - - $tmpGlobals['wgLocalInterwikis'] = [ 'local', 'mi' ]; - # "extra language links" - # see https://gerrit.wikimedia.org/r/111390 - $tmpGlobals['wgExtraInterlanguageLinkPrefixes'] = [ 'mul' ]; - - // DjVu support - $this->djVuSupport = new DjVuSupport(); - // Tidy support - $this->tidySupport = new TidySupport(); - $tmpGlobals['wgTidyConfig'] = null; - $tmpGlobals['wgUseTidy'] = false; - $tmpGlobals['wgDebugTidy'] = false; - $tmpGlobals['wgTidyConf'] = $IP . '/includes/tidy/tidy.conf'; - $tmpGlobals['wgTidyOpts'] = ''; - $tmpGlobals['wgTidyInternal'] = $this->tidySupport->isInternal(); - - $this->setMwGlobals( $tmpGlobals ); - - $this->savedWeirdGlobals['image_alias'] = $wgNamespaceAliases['Image']; - $this->savedWeirdGlobals['image_talk_alias'] = $wgNamespaceAliases['Image_talk']; - - $wgNamespaceAliases['Image'] = NS_FILE; - $wgNamespaceAliases['Image_talk'] = NS_FILE_TALK; - - MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache - $wgContLang->resetNamespaces(); # reset namespace cache - } - - protected function tearDown() { - global $wgNamespaceAliases, $wgContLang; - - $wgNamespaceAliases['Image'] = $this->savedWeirdGlobals['image_alias']; - $wgNamespaceAliases['Image_talk'] = $this->savedWeirdGlobals['image_talk_alias']; - - MWTidy::destroySingleton(); - - // Restore backends - RepoGroup::destroySingleton(); - FileBackendGroup::destroySingleton(); - - // Remove temporary pages from the link cache - LinkCache::singleton()->clear(); - - // Restore message cache (temporary pages and $wgUseDatabaseMessages) - MessageCache::destroyInstance(); - - parent::tearDown(); - - MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache - $wgContLang->resetNamespaces(); # reset namespace cache - } - - public static function tearDownAfterClass() { - ParserTest::tearDownInterwikis(); - parent::tearDownAfterClass(); - } - - function addDBDataOnce() { - # disabled for performance - # $this->tablesUsed[] = 'image'; - - # Update certain things in site_stats - $this->db->insert( 'site_stats', - [ 'ss_row_id' => 1, 'ss_images' => 2, 'ss_good_articles' => 1 ], - __METHOD__, - [ 'IGNORE' ] - ); - - $user = User::newFromId( 0 ); - LinkCache::singleton()->clear(); # Avoids the odd failure at creating the nullRevision - - # Upload DB table entries for files. - # We will upload the actual files later. Note that if anything causes LocalFile::load() - # to be triggered before then, it will break via maybeUpgrade() setting the fileExists - # member to false and storing it in cache. - # note that the size/width/height/bits/etc of the file - # are actually set by inspecting the file itself; the arguments - # to recordUpload2 have no effect. That said, we try to make things - # match up so it is less confusing to readers of the code & tests. - $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Foobar.jpg' ) ); - if ( !$this->db->selectField( 'image', '1', [ 'img_name' => $image->getName() ] ) ) { - $image->recordUpload2( - '', // archive name - 'Upload of some lame file', - 'Some lame file', - [ - 'size' => 7881, - 'width' => 1941, - 'height' => 220, - 'bits' => 8, - 'media_type' => MEDIATYPE_BITMAP, - 'mime' => 'image/jpeg', - 'metadata' => serialize( [] ), - 'sha1' => Wikimedia\base_convert( '1', 16, 36, 31 ), - 'fileExists' => true ], - $this->db->timestamp( '20010115123500' ), $user - ); - } - - $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Thumb.png' ) ); - if ( !$this->db->selectField( 'image', '1', [ 'img_name' => $image->getName() ] ) ) { - $image->recordUpload2( - '', // archive name - 'Upload of some lame thumbnail', - 'Some lame thumbnail', - [ - 'size' => 22589, - 'width' => 135, - 'height' => 135, - 'bits' => 8, - 'media_type' => MEDIATYPE_BITMAP, - 'mime' => 'image/png', - 'metadata' => serialize( [] ), - 'sha1' => Wikimedia\base_convert( '2', 16, 36, 31 ), - 'fileExists' => true ], - $this->db->timestamp( '20130225203040' ), $user - ); - } - - # This image will be blacklisted in [[MediaWiki:Bad image list]] - $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Bad.jpg' ) ); - if ( !$this->db->selectField( 'image', '1', [ 'img_name' => $image->getName() ] ) ) { - $image->recordUpload2( - '', // archive name - 'zomgnotcensored', - 'Borderline image', - [ - 'size' => 12345, - 'width' => 320, - 'height' => 240, - 'bits' => 24, - 'media_type' => MEDIATYPE_BITMAP, - 'mime' => 'image/jpeg', - 'metadata' => serialize( [] ), - 'sha1' => Wikimedia\base_convert( '3', 16, 36, 31 ), - 'fileExists' => true ], - $this->db->timestamp( '20010115123500' ), $user - ); - } - $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Foobar.svg' ) ); - if ( !$this->db->selectField( 'image', '1', [ 'img_name' => $image->getName() ] ) ) { - $image->recordUpload2( '', 'Upload of some lame SVG', 'Some lame SVG', [ - 'size' => 12345, - 'width' => 240, - 'height' => 180, - 'bits' => 0, - 'media_type' => MEDIATYPE_DRAWING, - 'mime' => 'image/svg+xml', - 'metadata' => serialize( [] ), - 'sha1' => Wikimedia\base_convert( '', 16, 36, 31 ), - 'fileExists' => true - ], $this->db->timestamp( '20010115123500' ), $user ); - } - - $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Video.ogv' ) ); - if ( !$this->db->selectField( 'image', '1', [ 'img_name' => $image->getName() ] ) ) { - $image->recordUpload2( '', 'A pretty movie', 'Will it play', [ - 'size' => 12345, - 'width' => 320, - 'height' => 240, - 'bits' => 0, - 'media_type' => MEDIATYPE_VIDEO, - 'mime' => 'application/ogg', - 'metadata' => serialize( [] ), - 'sha1' => Wikimedia\base_convert( '', 16, 36, 32 ), - 'fileExists' => true - ], $this->db->timestamp( '20010115123500' ), $user ); - } - - # A DjVu file - # A DjVu file - $image = wfLocalFile( Title::makeTitle( NS_FILE, 'LoremIpsum.djvu' ) ); - if ( !$this->db->selectField( 'image', '1', [ 'img_name' => $image->getName() ] ) ) { - $image->recordUpload2( '', 'Upload a DjVu', 'A DjVu', [ - 'size' => 3249, - 'width' => 2480, - 'height' => 3508, - 'bits' => 0, - 'media_type' => MEDIATYPE_BITMAP, - 'mime' => 'image/vnd.djvu', - 'metadata' => ' - - - - - - - - - - - - - - - - - - - - - - - - -', - 'sha1' => Wikimedia\base_convert( '', 16, 36, 31 ), - 'fileExists' => true - ], $this->db->timestamp( '20140115123600' ), $user ); - } - } - - // ParserTest setup/teardown functions - - /** - * Set up the global variables for a consistent environment for each test. - * Ideally this should replace the global configuration entirely. - * @param array $opts - * @param string $config - * @return RequestContext - */ - protected function setupGlobals( $opts = [], $config = '' ) { - global $wgFileBackends; - # Find out values for some special options. - $lang = - self::getOptionValue( 'language', $opts, 'en' ); - $variant = - self::getOptionValue( 'variant', $opts, false ); - $maxtoclevel = - self::getOptionValue( 'wgMaxTocLevel', $opts, 999 ); - $linkHolderBatchSize = - self::getOptionValue( 'wgLinkHolderBatchSize', $opts, 1000 ); - - $uploadDir = $this->getUploadDir(); - if ( $this->getCliArg( 'use-filebackend' ) ) { - if ( self::$backendToUse ) { - $backend = self::$backendToUse; - } else { - $name = $this->getCliArg( 'use-filebackend' ); - $useConfig = []; - foreach ( $wgFileBackends as $conf ) { - if ( $conf['name'] == $name ) { - $useConfig = $conf; - } - } - $useConfig['name'] = 'local-backend'; // swap name - unset( $useConfig['lockManager'] ); - unset( $useConfig['fileJournal'] ); - $class = $useConfig['class']; - self::$backendToUse = new $class( $useConfig ); - $backend = self::$backendToUse; - } - } else { - # Replace with a mock. We do not care about generating real - # files on the filesystem, just need to expose the file - # informations. - $backend = new MockFileBackend( [ - 'name' => 'local-backend', - 'wikiId' => wfWikiID() - ] ); - } - - $settings = [ - 'wgLocalFileRepo' => [ - 'class' => 'LocalRepo', - 'name' => 'local', - 'url' => 'http://example.com/images', - 'hashLevels' => 2, - 'transformVia404' => false, - 'backend' => $backend - ], - 'wgEnableUploads' => self::getOptionValue( 'wgEnableUploads', $opts, true ), - 'wgLanguageCode' => $lang, - 'wgDBprefix' => $this->db->getType() != 'oracle' ? 'unittest_' : 'ut_', - 'wgRawHtml' => self::getOptionValue( 'wgRawHtml', $opts, false ), - 'wgNamespacesWithSubpages' => [ NS_MAIN => isset( $opts['subpage'] ) ], - 'wgAllowExternalImages' => self::getOptionValue( 'wgAllowExternalImages', $opts, true ), - 'wgThumbLimits' => [ self::getOptionValue( 'thumbsize', $opts, 180 ) ], - 'wgMaxTocLevel' => $maxtoclevel, - 'wgUseTeX' => isset( $opts['math'] ) || isset( $opts['texvc'] ), - 'wgWellFormedXml' => true, - 'wgMathDirectory' => $uploadDir . '/math', - 'wgDefaultLanguageVariant' => $variant, - 'wgLinkHolderBatchSize' => $linkHolderBatchSize, - 'wgUseTidy' => isset( $opts['tidy'] ), - ]; - - if ( $config ) { - $configLines = explode( "\n", $config ); - - foreach ( $configLines as $line ) { - list( $var, $value ) = explode( '=', $line, 2 ); - - $settings[$var] = eval( "return $value;" ); // ??? - } - } - - $this->savedGlobals = []; - - /** @since 1.20 */ - Hooks::run( 'ParserTestGlobals', [ &$settings ] ); - - $langObj = Language::factory( $lang ); - $settings['wgContLang'] = $langObj; - $settings['wgLang'] = $langObj; - - $context = new RequestContext(); - $settings['wgOut'] = $context->getOutput(); - $settings['wgUser'] = $context->getUser(); - $settings['wgRequest'] = $context->getRequest(); - - // We (re)set $wgThumbLimits to a single-element array above. - $context->getUser()->setOption( 'thumbsize', 0 ); - - foreach ( $settings as $var => $val ) { - if ( array_key_exists( $var, $GLOBALS ) ) { - $this->savedGlobals[$var] = $GLOBALS[$var]; - } - - $GLOBALS[$var] = $val; - } - - MWTidy::destroySingleton(); - MagicWord::clearCache(); - - # The entries saved into RepoGroup cache with previous globals will be wrong. - RepoGroup::destroySingleton(); - FileBackendGroup::destroySingleton(); - - # Create dummy files in storage - $this->setupUploads(); - - # Publish the articles after we have the final language set - $this->publishTestArticles(); - - MessageCache::destroyInstance(); - - return $context; - } - - /** - * Get an FS upload directory (only applies to FSFileBackend) - * - * @return string The directory - */ - protected function getUploadDir() { - if ( $this->keepUploads ) { - // Don't use getNewTempDirectory() as this is meant to persist - $dir = wfTempDir() . '/mwParser-images'; - - if ( is_dir( $dir ) ) { - return $dir; - } - } else { - $dir = $this->getNewTempDirectory(); - } - - if ( file_exists( $dir ) ) { - wfDebug( "Already exists!\n" ); - - return $dir; - } - - return $dir; - } - - /** - * Create a dummy uploads directory which will contain a couple - * of files in order to pass existence tests. - * - * @return string The directory - */ - protected function setupUploads() { - global $IP; - - $base = $this->getBaseDir(); - $backend = RepoGroup::singleton()->getLocalRepo()->getBackend(); - $backend->prepare( [ 'dir' => "$base/local-public/3/3a" ] ); - $backend->store( [ - 'src' => "$IP/tests/phpunit/data/parser/headbg.jpg", - 'dst' => "$base/local-public/3/3a/Foobar.jpg" - ] ); - $backend->prepare( [ 'dir' => "$base/local-public/e/ea" ] ); - $backend->store( [ - 'src' => "$IP/tests/phpunit/data/parser/wiki.png", - 'dst' => "$base/local-public/e/ea/Thumb.png" - ] ); - $backend->prepare( [ 'dir' => "$base/local-public/0/09" ] ); - $backend->store( [ - 'src' => "$IP/tests/phpunit/data/parser/headbg.jpg", - 'dst' => "$base/local-public/0/09/Bad.jpg" - ] ); - $backend->prepare( [ 'dir' => "$base/local-public/5/5f" ] ); - $backend->store( [ - 'src' => "$IP/tests/phpunit/data/parser/LoremIpsum.djvu", - 'dst' => "$base/local-public/5/5f/LoremIpsum.djvu" - ] ); - - // No helpful SVG file to copy, so make one ourselves - $data = '' . - ''; - - $backend->prepare( [ 'dir' => "$base/local-public/f/ff" ] ); - $backend->quickCreate( [ - 'content' => $data, 'dst' => "$base/local-public/f/ff/Foobar.svg" - ] ); - } - - /** - * Restore default values and perform any necessary clean-up - * after each test runs. - */ - protected function teardownGlobals() { - $this->teardownUploads(); - - foreach ( $this->savedGlobals as $var => $val ) { - $GLOBALS[$var] = $val; - } - } - - /** - * Remove the dummy uploads directory - */ - private function teardownUploads() { - if ( $this->keepUploads ) { - return; - } - - $backend = RepoGroup::singleton()->getLocalRepo()->getBackend(); - if ( $backend instanceof MockFileBackend ) { - # In memory backend, so dont bother cleaning them up. - return; - } - - $base = $this->getBaseDir(); - // delete the files first, then the dirs. - self::deleteFiles( - [ - "$base/local-public/3/3a/Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/1000px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/100px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/120px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/1280px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/137px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/1500px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/177px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/180px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/200px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/206px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/20px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/220px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/265px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/270px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/274px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/300px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/30px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/330px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/353px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/360px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/400px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/40px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/440px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/442px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/450px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/50px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/600px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/640px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/70px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/75px-Foobar.jpg", - "$base/local-thumb/3/3a/Foobar.jpg/960px-Foobar.jpg", - - "$base/local-public/e/ea/Thumb.png", - - "$base/local-public/0/09/Bad.jpg", - - "$base/local-public/5/5f/LoremIpsum.djvu", - "$base/local-thumb/5/5f/LoremIpsum.djvu/page2-2480px-LoremIpsum.djvu.jpg", - "$base/local-thumb/5/5f/LoremIpsum.djvu/page2-3720px-LoremIpsum.djvu.jpg", - "$base/local-thumb/5/5f/LoremIpsum.djvu/page2-4960px-LoremIpsum.djvu.jpg", - - "$base/local-public/f/ff/Foobar.svg", - "$base/local-thumb/f/ff/Foobar.svg/180px-Foobar.svg.png", - "$base/local-thumb/f/ff/Foobar.svg/2000px-Foobar.svg.png", - "$base/local-thumb/f/ff/Foobar.svg/270px-Foobar.svg.png", - "$base/local-thumb/f/ff/Foobar.svg/3000px-Foobar.svg.png", - "$base/local-thumb/f/ff/Foobar.svg/360px-Foobar.svg.png", - "$base/local-thumb/f/ff/Foobar.svg/4000px-Foobar.svg.png", - "$base/local-thumb/f/ff/Foobar.svg/langde-180px-Foobar.svg.png", - "$base/local-thumb/f/ff/Foobar.svg/langde-270px-Foobar.svg.png", - "$base/local-thumb/f/ff/Foobar.svg/langde-360px-Foobar.svg.png", - - "$base/local-public/math/f/a/5/fa50b8b616463173474302ca3e63586b.png", - ] - ); - } - - /** - * Delete the specified files, if they exist. - * @param array $files Full paths to files to delete. - */ - private static function deleteFiles( $files ) { - $backend = RepoGroup::singleton()->getLocalRepo()->getBackend(); - foreach ( $files as $file ) { - $backend->delete( [ 'src' => $file ], [ 'force' => 1 ] ); - } - foreach ( $files as $file ) { - $tmp = FileBackend::parentStoragePath( $file ); - while ( $tmp ) { - if ( !$backend->clean( [ 'dir' => $tmp ] )->isOK() ) { - break; - } - $tmp = FileBackend::parentStoragePath( $tmp ); - } - } - } - - protected function getBaseDir() { - return 'mwstore://local-backend'; - } - - public function parserTestProvider() { - if ( $this->file === false ) { - global $wgParserTestFiles; - $this->file = $wgParserTestFiles[0]; - } - - return new TestFileDataProvider( $this->file, $this ); - } - - /** - * Set the file from whose tests will be run by this instance - * @param string $filename - */ - public function setParserTestFile( $filename ) { - $this->file = $filename; - } - - /** - * @group medium - * @group ParserTests - * @dataProvider parserTestProvider - * @param string $desc - * @param string $input - * @param string $result - * @param array $opts - * @param array $config - */ - public function testParserTest( $desc, $input, $result, $opts, $config ) { - if ( $this->regex != '' && !preg_match( '/' . $this->regex . '/', $desc ) ) { - $this->assertTrue( true ); // XXX: don't flood output with "test made no assertions" - // $this->markTestSkipped( 'Filtered out by the user' ); - return; - } - - if ( !$this->isWikitextNS( NS_MAIN ) ) { - // parser tests frequently assume that the main namespace contains wikitext. - // @todo When setting up pages, force the content model. Only skip if - // $wgtContentModelUseDB is false. - $this->markTestSkipped( "Main namespace does not support wikitext," - . "skipping parser test: $desc" ); - } - - wfDebug( "Running parser test: $desc\n" ); - - $opts = $this->parseOptions( $opts ); - $context = $this->setupGlobals( $opts, $config ); - - $user = $context->getUser(); - $options = ParserOptions::newFromContext( $context ); - - if ( isset( $opts['title'] ) ) { - $titleText = $opts['title']; - } else { - $titleText = 'Parser test'; - } - - $local = isset( $opts['local'] ); - $preprocessor = isset( $opts['preprocessor'] ) ? $opts['preprocessor'] : null; - $parser = $this->getParser( $preprocessor ); - - $title = Title::newFromText( $titleText ); - - # Parser test requiring math. Make sure texvc is executable - # or just skip such tests. - if ( isset( $opts['math'] ) || isset( $opts['texvc'] ) ) { - global $wgTexvc; - - if ( !isset( $wgTexvc ) ) { - $this->markTestSkipped( "SKIPPED: \$wgTexvc is not set" ); - } elseif ( !is_executable( $wgTexvc ) ) { - $this->markTestSkipped( "SKIPPED: texvc binary does not exist" - . " or is not executable.\n" - . "Current configuration is:\n\$wgTexvc = '$wgTexvc'" ); - } - } - - if ( isset( $opts['djvu'] ) ) { - if ( !$this->djVuSupport->isEnabled() ) { - $this->markTestSkipped( "SKIPPED: djvu binaries do not exist or are not executable.\n" ); - } - } - - if ( isset( $opts['tidy'] ) ) { - if ( !$this->tidySupport->isEnabled() ) { - $this->markTestSkipped( "SKIPPED: tidy extension is not installed.\n" ); - } else { - $options->setTidy( true ); - } - } - - if ( isset( $opts['pst'] ) ) { - $out = $parser->preSaveTransform( $input, $title, $user, $options ); - } elseif ( isset( $opts['msg'] ) ) { - $out = $parser->transformMsg( $input, $options, $title ); - } elseif ( isset( $opts['section'] ) ) { - $section = $opts['section']; - $out = $parser->getSection( $input, $section ); - } elseif ( isset( $opts['replace'] ) ) { - $section = $opts['replace'][0]; - $replace = $opts['replace'][1]; - $out = $parser->replaceSection( $input, $section, $replace ); - } elseif ( isset( $opts['comment'] ) ) { - $out = Linker::formatComment( $input, $title, $local ); - } elseif ( isset( $opts['preload'] ) ) { - $out = $parser->getPreloadText( $input, $title, $options ); - } else { - $output = $parser->parse( $input, $title, $options, true, true, 1337 ); - $output->setTOCEnabled( !isset( $opts['notoc'] ) ); - $out = $output->getText(); - if ( isset( $opts['tidy'] ) ) { - $out = preg_replace( '/\s+$/', '', $out ); - } - - if ( isset( $opts['showtitle'] ) ) { - if ( $output->getTitleText() ) { - $title = $output->getTitleText(); - } - - $out = "$title\n$out"; - } - - if ( isset( $opts['showindicators'] ) ) { - $indicators = ''; - foreach ( $output->getIndicators() as $id => $content ) { - $indicators .= "$id=$content\n"; - } - $out = $indicators . $out; - } - - if ( isset( $opts['ill'] ) ) { - $out = implode( ' ', $output->getLanguageLinks() ); - } elseif ( isset( $opts['cat'] ) ) { - $outputPage = $context->getOutput(); - $outputPage->addCategoryLinks( $output->getCategories() ); - $cats = $outputPage->getCategoryLinks(); - - if ( isset( $cats['normal'] ) ) { - $out = implode( ' ', $cats['normal'] ); - } else { - $out = ''; - } - } - $parser->mPreprocessor = null; - } - - $this->teardownGlobals(); - - $this->assertEquals( $result, $out, $desc ); - } - - /** - * Run a fuzz test series - * Draw input from a set of test files - * - * @todo fixme Needs some work to not eat memory until the world explodes - * - * @group ParserFuzz - */ - public function testFuzzTests() { - global $wgParserTestFiles; - - $files = $wgParserTestFiles; - - if ( $this->getCliArg( 'file' ) ) { - $files = [ $this->getCliArg( 'file' ) ]; - } - - $dict = $this->getFuzzInput( $files ); - $dictSize = strlen( $dict ); - $logMaxLength = log( $this->maxFuzzTestLength ); - - ini_set( 'memory_limit', $this->memoryLimit * 1048576 ); - - $user = new User; - $opts = ParserOptions::newFromUser( $user ); - $title = Title::makeTitle( NS_MAIN, 'Parser_test' ); - - $id = 1; - - while ( true ) { - - // Generate test input - mt_srand( ++$this->fuzzSeed ); - $totalLength = mt_rand( 1, $this->maxFuzzTestLength ); - $input = ''; - - while ( strlen( $input ) < $totalLength ) { - $logHairLength = mt_rand( 0, 1000000 ) / 1000000 * $logMaxLength; - $hairLength = min( intval( exp( $logHairLength ) ), $dictSize ); - $offset = mt_rand( 0, $dictSize - $hairLength ); - $input .= substr( $dict, $offset, $hairLength ); - } - - $this->setupGlobals(); - $parser = $this->getParser(); - - // Run the test - try { - $parser->parse( $input, $title, $opts ); - $this->assertTrue( true, "Test $id, fuzz seed {$this->fuzzSeed}" ); - } catch ( Exception $exception ) { - $input_dump = sprintf( "string(%d) \"%s\"\n", strlen( $input ), $input ); - - $this->assertTrue( false, "Test $id, fuzz seed {$this->fuzzSeed}. \n\n" . - "Input: $input_dump\n\nError: {$exception->getMessage()}\n\n" . - "Backtrace: {$exception->getTraceAsString()}" ); - } - - $this->teardownGlobals(); - $parser->__destruct(); - - if ( $id % 100 == 0 ) { - $usage = intval( memory_get_usage( true ) / $this->memoryLimit / 1048576 * 100 ); - // echo "{$this->fuzzSeed}: $numSuccess/$numTotal (mem: $usage%)\n"; - if ( $usage > 90 ) { - $ret = "Out of memory:\n"; - $memStats = $this->getMemoryBreakdown(); - - foreach ( $memStats as $name => $usage ) { - $ret .= "$name: $usage\n"; - } - - throw new MWException( $ret ); - } - } - - $id++; - } - } - - // Various getter functions - - /** - * Get an input dictionary from a set of parser test files - * @param array $filenames - * @return string - */ - function getFuzzInput( $filenames ) { - $dict = ''; - - foreach ( $filenames as $filename ) { - $contents = file_get_contents( $filename ); - preg_match_all( '/!!\s*input\n(.*?)\n!!\s*result/s', $contents, $matches ); - - foreach ( $matches[1] as $match ) { - $dict .= $match . "\n"; - } - } - - return $dict; - } - - /** - * Get a memory usage breakdown - * @return array - */ - function getMemoryBreakdown() { - $memStats = []; - - foreach ( $GLOBALS as $name => $value ) { - $memStats['$' . $name] = strlen( serialize( $value ) ); - } - - $classes = get_declared_classes(); - - foreach ( $classes as $class ) { - $rc = new ReflectionClass( $class ); - $props = $rc->getStaticProperties(); - $memStats[$class] = strlen( serialize( $props ) ); - $methods = $rc->getMethods(); - - foreach ( $methods as $method ) { - $memStats[$class] += strlen( serialize( $method->getStaticVariables() ) ); - } - } - - $functions = get_defined_functions(); - - foreach ( $functions['user'] as $function ) { - $rf = new ReflectionFunction( $function ); - $memStats["$function()"] = strlen( serialize( $rf->getStaticVariables() ) ); - } - - asort( $memStats ); - - return $memStats; - } - - /** - * Get a Parser object - * @param Preprocessor $preprocessor - * @return Parser - */ - function getParser( $preprocessor = null ) { - global $wgParserConf; - - $class = $wgParserConf['class']; - $parser = new $class( [ 'preprocessorClass' => $preprocessor ] + $wgParserConf ); - - Hooks::run( 'ParserTestParser', [ &$parser ] ); - - return $parser; - } - - // Various action functions - - public function addArticle( $name, $text, $line ) { - self::$articles[$name] = [ $text, $line ]; - } - - public function publishTestArticles() { - if ( empty( self::$articles ) ) { - return; - } - - foreach ( self::$articles as $name => $info ) { - list( $text, $line ) = $info; - ParserTest::addArticle( $name, $text, $line, 'ignoreduplicate' ); - } - } - - /** - * Steal a callback function from the primary parser, save it for - * application to our scary parser. If the hook is not installed, - * abort processing of this file. - * - * @param string $name - * @return bool True if tag hook is present - */ - public function requireHook( $name ) { - global $wgParser; - $wgParser->firstCallInit(); // make sure hooks are loaded. - return isset( $wgParser->mTagHooks[$name] ); - } - - public function requireFunctionHook( $name ) { - global $wgParser; - $wgParser->firstCallInit(); // make sure hooks are loaded. - return isset( $wgParser->mFunctionHooks[$name] ); - } - - public function requireTransparentHook( $name ) { - global $wgParser; - $wgParser->firstCallInit(); // make sure hooks are loaded. - return isset( $wgParser->mTransparentTagHooks[$name] ); - } - - // Various "cleanup" functions - - /** - * Remove last character if it is a newline - * @param string $s - * @return string - */ - public function removeEndingNewline( $s ) { - if ( substr( $s, -1 ) === "\n" ) { - return substr( $s, 0, -1 ); - } else { - return $s; - } - } - - // Test options parser functions - - protected function parseOptions( $instring ) { - $opts = []; - // foo - // foo=bar - // foo="bar baz" - // foo=[[bar baz]] - // foo=bar,"baz quux" - $regex = '/\b - ([\w-]+) # Key - \b - (?:\s* - = # First sub-value - \s* - ( - " - [^"]* # Quoted val - " - | - \[\[ - [^]]* # Link target - \]\] - | - [\w-]+ # Plain word - ) - (?:\s* - , # Sub-vals 1..N - \s* - ( - "[^"]*" # Quoted val - | - \[\[[^]]*\]\] # Link target - | - [\w-]+ # Plain word - ) - )* - )? - /x'; - - if ( preg_match_all( $regex, $instring, $matches, PREG_SET_ORDER ) ) { - foreach ( $matches as $bits ) { - array_shift( $bits ); - $key = strtolower( array_shift( $bits ) ); - if ( count( $bits ) == 0 ) { - $opts[$key] = true; - } elseif ( count( $bits ) == 1 ) { - $opts[$key] = $this->cleanupOption( array_shift( $bits ) ); - } else { - // Array! - $opts[$key] = array_map( [ $this, 'cleanupOption' ], $bits ); - } - } - } - - return $opts; - } - - protected function cleanupOption( $opt ) { - if ( substr( $opt, 0, 1 ) == '"' ) { - return substr( $opt, 1, -1 ); - } - - if ( substr( $opt, 0, 2 ) == '[[' ) { - return substr( $opt, 2, -2 ); - } - - return $opt; - } - - /** - * Use a regex to find out the value of an option - * @param string $key Name of option val to retrieve - * @param array $opts Options array to look in - * @param mixed $default Default value returned if not found - * @return mixed - */ - protected static function getOptionValue( $key, $opts, $default ) { - $key = strtolower( $key ); - - if ( isset( $opts[$key] ) ) { - return $opts[$key]; - } else { - return $default; - } - } -} diff --git a/tests/phpunit/includes/parser/ParserIntegrationTest.php b/tests/phpunit/includes/parser/ParserIntegrationTest.php new file mode 100644 index 0000000000..c92098248c --- /dev/null +++ b/tests/phpunit/includes/parser/ParserIntegrationTest.php @@ -0,0 +1,49 @@ +ptTest = $test; + $this->ptRunner = $runner; + } + + public function testParse() { + $this->ptRunner->getRecorder()->setTestCase( $this ); + $result = $this->ptRunner->runTest( $this->ptTest ); + $this->assertEquals( $result->expected, $result->actual ); + } + + public function setUp() { + $this->ptTeardownScope = $this->ptRunner->staticSetup(); + } + + public function tearDown() { + if ( $this->ptTeardownScope ) { + ScopedCallback::consume( $this->ptTeardownScope ); + } + } +} diff --git a/tests/phpunit/includes/parser/PreprocessorTest.php b/tests/phpunit/includes/parser/PreprocessorTest.php index a62503a629..c491e6b829 100644 --- a/tests/phpunit/includes/parser/PreprocessorTest.php +++ b/tests/phpunit/includes/parser/PreprocessorTest.php @@ -1,5 +1,29 @@ mOptions = ParserOptions::newFromUserAndLang( new User, $wgContLang ); - $name = isset( $wgParserConf['preprocessorClass'] ) - ? $wgParserConf['preprocessorClass'] - : 'Preprocessor_DOM'; - $this->mPreprocessor = new $name( $this ); + $this->mPreprocessors = []; + foreach ( self::$classNames as $className ) { + $this->mPreprocessors[$className] = new $className( $this ); + } } function getStripList() { return [ 'gallery', 'display map' /* Used by Maps, see r80025 CR */, '/foo' ]; } + protected static function addClassArg( $testCases ) { + $newTestCases = []; + foreach ( self::$classNames as $className ) { + foreach ( $testCases as $testCase ) { + array_unshift( $testCase, $className ); + $newTestCases[] = $testCase; + } + } + return $newTestCases; + } + public static function provideCases() { // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong - return [ + return self::addClassArg( [ [ "Foo", "Foo" ], [ "", "<!-- Foo -->" ], [ "", "<!-- Foo --><!-- Bar -->" ], @@ -115,7 +155,7 @@ class PreprocessorTest extends MediaWikiTestCase { [ "{{Foo|} Bar=", "{{Foo|} Bar=" ], [ "{{Foo|} Bar=}}", "" ], /* [ file_get_contents( __DIR__ . '/QuoteQuran.txt' ], file_get_contents( __DIR__ . '/QuoteQuranExpanded.txt' ) ], */ - ]; + ] ); // @codingStandardsIgnoreEnd } @@ -123,15 +163,17 @@ class PreprocessorTest extends MediaWikiTestCase { * Get XML preprocessor tree from the preprocessor (which may not be the * native XML-based one). * + * @param string $className * @param string $wikiText * @return string */ - protected function preprocessToXml( $wikiText ) { - if ( method_exists( $this->mPreprocessor, 'preprocessToXml' ) ) { - return $this->normalizeXml( $this->mPreprocessor->preprocessToXml( $wikiText ) ); + protected function preprocessToXml( $className, $wikiText ) { + $preprocessor = $this->mPreprocessors[$className]; + if ( method_exists( $preprocessor, 'preprocessToXml' ) ) { + return $this->normalizeXml( $preprocessor->preprocessToXml( $wikiText ) ); } - $dom = $this->mPreprocessor->preprocessToObj( $wikiText ); + $dom = $preprocessor->preprocessToObj( $wikiText ); if ( is_callable( [ $dom, 'saveXML' ] ) ) { return $dom->saveXML(); } else { @@ -146,15 +188,20 @@ class PreprocessorTest extends MediaWikiTestCase { * @return string */ protected function normalizeXml( $xml ) { - return preg_replace( '!<([a-z]+)/>!', '<$1>', str_replace( ' />', '/>', $xml ) ); + // Normalize self-closing tags + $xml = preg_replace( '!<([a-z]+)/>!', '<$1>', str_replace( ' />', '/>', $xml ) ); + // Remove tags, which only occur in Preprocessor_Hash and + // have no semantic value + $xml = preg_replace( '!!', '', $xml ); + return $xml; } /** * @dataProvider provideCases - * @covers Preprocessor_DOM::preprocessToXml */ - public function testPreprocessorOutput( $wikiText, $expectedXml ) { - $this->assertEquals( $this->normalizeXml( $expectedXml ), $this->preprocessToXml( $wikiText ) ); + public function testPreprocessorOutput( $className, $wikiText, $expectedXml ) { + $this->assertEquals( $this->normalizeXml( $expectedXml ), + $this->preprocessToXml( $className, $wikiText ) ); } /** @@ -162,24 +209,23 @@ class PreprocessorTest extends MediaWikiTestCase { */ public static function provideFiles() { // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong - return [ - [ "QuoteQuran" ], # http://en.wikipedia.org/w/index.php?title=Template:QuoteQuran/sandbox&oldid=237348988 GFDL + CC BY-SA by Striver - [ "Factorial" ], # http://en.wikipedia.org/w/index.php?title=Template:Factorial&oldid=98548758 GFDL + CC BY-SA by Polonium - [ "All_system_messages" ], # http://tl.wiktionary.org/w/index.php?title=Suleras:All_system_messages&oldid=2765 GPL text generated by MediaWiki - [ "Fundraising" ], # http://tl.wiktionary.org/w/index.php?title=MediaWiki:Sitenotice&oldid=5716 GFDL + CC BY-SA, copied there by Sky Harbor. + return self::addClassArg( [ + [ "QuoteQuran" ], # https://en.wikipedia.org/w/index.php?title=Template:QuoteQuran/sandbox&oldid=237348988 GFDL + CC BY-SA by Striver + [ "Factorial" ], # https://en.wikipedia.org/w/index.php?title=Template:Factorial&oldid=98548758 GFDL + CC BY-SA by Polonium + [ "All_system_messages" ], # https://tl.wiktionary.org/w/index.php?title=Suleras:All_system_messages&oldid=2765 GPL text generated by MediaWiki + [ "Fundraising" ], # https://tl.wiktionary.org/w/index.php?title=MediaWiki:Sitenotice&oldid=5716 GFDL + CC BY-SA, copied there by Sky Harbor. [ "NestedTemplates" ], # bug 27936 - ]; + ] ); // @codingStandardsIgnoreEnd } /** * @dataProvider provideFiles - * @covers Preprocessor_DOM::preprocessToXml */ - public function testPreprocessorOutputFiles( $filename ) { + public function testPreprocessorOutputFiles( $className, $filename ) { $folder = __DIR__ . "/../../../parser/preprocess"; $wikiText = file_get_contents( "$folder/$filename.txt" ); - $output = $this->preprocessToXml( $wikiText ); + $output = $this->preprocessToXml( $className, $wikiText ); $expectedFilename = "$folder/$filename.expected"; if ( file_exists( $expectedFilename ) ) { @@ -197,7 +243,8 @@ class PreprocessorTest extends MediaWikiTestCase { */ public static function provideHeadings() { // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong - return [ /* These should become headings: */ + return self::addClassArg( [ + /* These should become headings: */ [ "== h ==", "== h ==<!--c1-->" ], [ "== h == ", "== h == <!--c1-->" ], [ "== h == ", "== h ==<!--c1--> " ], @@ -233,15 +280,15 @@ class PreprocessorTest extends MediaWikiTestCase { [ "== h == x ", "== h == x <!--c1--><!--c2--><!--c3--> " ], [ "== h == x ", "== h ==<!--c1--> x <!--c2--><!--c3--> " ], [ "== h == x ", "== h ==<!--c1--><!--c2--><!--c3--> x " ], - ]; + ] ); // @codingStandardsIgnoreEnd } /** * @dataProvider provideHeadings - * @covers Preprocessor_DOM::preprocessToXml */ - public function testHeadings( $wikiText, $expectedXml ) { - $this->assertEquals( $this->normalizeXml( $expectedXml ), $this->preprocessToXml( $wikiText ) ); + public function testHeadings( $className, $wikiText, $expectedXml ) { + $this->assertEquals( $this->normalizeXml( $expectedXml ), + $this->preprocessToXml( $className, $wikiText ) ); } } diff --git a/tests/phpunit/includes/phpunit/ConsecutiveParametersMatcher.php b/tests/phpunit/includes/phpunit/ConsecutiveParametersMatcher.php deleted file mode 100644 index 8de467fee4..0000000000 --- a/tests/phpunit/includes/phpunit/ConsecutiveParametersMatcher.php +++ /dev/null @@ -1,124 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/** - * Invocation matcher which looks for sets of specific parameters in the invocations. - * - * Checks the parameters of the incoming invocations, the parameter list is - * checked against the defined constraints in $parameters. If the constraint - * is met it will return true in matches(). - * - * It takes a list of match groups and and increases a call index after each invocation. - * So the first invocation uses the first group of constraints, the second the next and so on. - */ -class PHPUnit_Framework_MockObject_Matcher_ConsecutiveParameters extends PHPUnit_Framework_MockObject_Matcher_StatelessInvocation -{ - /** - * @var array - */ - private $_parameterGroups = array(); - - /** - * @var array - */ - private $_invocations = array(); - - /** - * @param array $parameterGroups - */ - public function __construct(array $parameterGroups) - { - foreach ($parameterGroups as $index => $parameters) { - foreach ($parameters as $parameter) { - if (!($parameter instanceof \PHPUnit_Framework_Constraint)) { - $parameter = new \PHPUnit_Framework_Constraint_IsEqual($parameter); - } - $this->_parameterGroups[$index][] = $parameter; - } - } - } - - /** - * @return string - */ - public function toString() - { - $text = 'with consecutive parameters'; - - return $text; - } - - /** - * @param PHPUnit_Framework_MockObject_Invocation $invocation - * @return bool - */ - public function matches(PHPUnit_Framework_MockObject_Invocation $invocation) - { - $this->_invocations[] = $invocation; - $callIndex = count($this->_invocations) - 1; - $this->verifyInvocation($invocation, $callIndex); - - return false; - } - - public function verify() - { - foreach ($this->_invocations as $callIndex => $invocation) { - $this->verifyInvocation($invocation, $callIndex); - } - } - - /** - * Verify a single invocation - * - * @param PHPUnit_Framework_MockObject_Invocation $invocation - * @param int $callIndex - * @throws PHPUnit_Framework_ExpectationFailedException - */ - private function verifyInvocation(PHPUnit_Framework_MockObject_Invocation $invocation, $callIndex) - { - - if (isset($this->_parameterGroups[$callIndex])) { - $parameters = $this->_parameterGroups[$callIndex]; - } else { - // no parameter assertion for this call index - return; - } - - if ($invocation === null) { - throw new PHPUnit_Framework_ExpectationFailedException( - 'Mocked method does not exist.' - ); - } - - if (count($invocation->parameters) < count($parameters)) { - throw new PHPUnit_Framework_ExpectationFailedException( - sprintf( - 'Parameter count for invocation %s is too low.', - $invocation->toString() - ) - ); - } - - foreach ($parameters as $i => $parameter) { - $parameter->evaluate( - $invocation->parameters[$i], - sprintf( - 'Parameter %s for invocation #%d %s does not match expected ' . - 'value.', - $i, - $callIndex, - $invocation->toString() - ) - ); - } - } -} diff --git a/tests/phpunit/includes/phpunit/LICENSE b/tests/phpunit/includes/phpunit/LICENSE deleted file mode 100644 index fe178b0835..0000000000 --- a/tests/phpunit/includes/phpunit/LICENSE +++ /dev/null @@ -1,33 +0,0 @@ -PHPUnit - -Copyright (c) 2001-2014, Sebastian Bergmann . -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions -are met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in - the documentation and/or other materials provided with the - distribution. - - * Neither the name of Sebastian Bergmann nor the names of his - contributors may be used to endorse or promote products derived - from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE -COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, -INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, -BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT -LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN -ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. diff --git a/tests/phpunit/includes/phpunit/README b/tests/phpunit/includes/phpunit/README deleted file mode 100644 index 3ec3fd9274..0000000000 --- a/tests/phpunit/includes/phpunit/README +++ /dev/null @@ -1,2 +0,0 @@ -This directory contains classes duplicated from new versions of phpunit -that also work in the older php 3.7.37 used by wmf CI servers. diff --git a/tests/phpunit/includes/registration/CoreVersionCheckerTest.php b/tests/phpunit/includes/registration/CoreVersionCheckerTest.php index 4aa4f41524..1dfcd82226 100644 --- a/tests/phpunit/includes/registration/CoreVersionCheckerTest.php +++ b/tests/phpunit/includes/registration/CoreVersionCheckerTest.php @@ -14,7 +14,7 @@ class CoreVersionCheckerTest extends PHPUnit_Framework_TestCase { public static function provideCheck() { return [ - // array( $wgVersion, constraint, expected ) + // [ $wgVersion, constraint, expected ] [ '1.25alpha', '>= 1.26', false ], [ '1.25.0', '>= 1.26', false ], [ '1.26alpha', '>= 1.26', true ], diff --git a/tests/phpunit/includes/registration/ExtensionProcessorTest.php b/tests/phpunit/includes/registration/ExtensionProcessorTest.php index 0120d79ec5..11995de944 100644 --- a/tests/phpunit/includes/registration/ExtensionProcessorTest.php +++ b/tests/phpunit/includes/registration/ExtensionProcessorTest.php @@ -2,11 +2,12 @@ class ExtensionProcessorTest extends MediaWikiTestCase { - private $dir; + private $dir, $dirname; public function setUp() { parent::setUp(); $this->dir = __DIR__ . '/FooBar/extension.json'; + $this->dirname = dirname( $this->dir ); } /** @@ -108,9 +109,9 @@ class ExtensionProcessorTest extends MediaWikiTestCase { } /** - * @covers ExtensionProcessor::extractConfig + * @covers ExtensionProcessor::extractConfig1 */ - public function testExtractConfig() { + public function testExtractConfig1() { $processor = new ExtensionProcessor; $info = [ 'config' => [ @@ -136,6 +137,35 @@ class ExtensionProcessorTest extends MediaWikiTestCase { $this->assertEquals( 'somevalue', $extracted['globals']['egBar'] ); } + /** + * @covers ExtensionProcessor::extractConfig2 + */ + public function testExtractConfig2() { + $processor = new ExtensionProcessor; + $info = [ + 'config' => [ + 'Bar' => [ 'value' => 'somevalue' ], + 'Foo' => [ 'value' => 10 ], + 'Path' => [ 'value' => 'foo.txt', 'path' => true ], + ], + ] + self::$default; + $info2 = [ + 'config' => [ + 'Bar' => [ 'value' => 'somevalue' ], + ], + 'config_prefix' => 'eg', + 'name' => 'FooBar2', + ]; + $processor->extractInfo( $this->dir, $info, 2 ); + $processor->extractInfo( $this->dir, $info2, 2 ); + $extracted = $processor->getExtractedInfo(); + $this->assertEquals( 'somevalue', $extracted['globals']['wgBar'] ); + $this->assertEquals( 10, $extracted['globals']['wgFoo'] ); + $this->assertEquals( "{$this->dirname}/foo.txt", $extracted['globals']['wgPath'] ); + // Custom prefix: + $this->assertEquals( 'somevalue', $extracted['globals']['egBar'] ); + } + public static function provideExtractExtensionMessagesFiles() { $dir = __DIR__ . '/FooBar/'; return [ @@ -414,6 +444,26 @@ class ExtensionProcessorTest extends MediaWikiTestCase { ] ]; } + + public function testGlobalSettingsDocumentedInSchema() { + global $IP; + $globalSettings = TestingAccessWrapper::newFromClass( + ExtensionProcessor::class )->globalSettings; + + $schema = FormatJson::decode( + file_get_contents( "$IP/docs/extension.schema.json" ), + true + ); + $missing = []; + foreach ( $globalSettings as $global ) { + if ( !isset( $schema['properties'][$global] ) ) { + $missing[] = $global; + } + } + + $this->assertEquals( [], $missing, + "The following global settings are not documented in docs/extension.schema.json" ); + } } /** diff --git a/tests/phpunit/includes/registration/ExtensionRegistryTest.php b/tests/phpunit/includes/registration/ExtensionRegistryTest.php index 167f52a16f..9b57e1c3d1 100644 --- a/tests/phpunit/includes/registration/ExtensionRegistryTest.php +++ b/tests/phpunit/includes/registration/ExtensionRegistryTest.php @@ -252,6 +252,41 @@ class ExtensionRegistryTest extends MediaWikiTestCase { 'mwtestT100767' => false, ], ], + [ + 'test array_replace_recursive', + [ + 'mwtestJsonConfigs' => [ + 'JsonZeroConfig' => [ + 'namespace' => 480, + 'nsName' => 'Zero', + 'isLocal' => true, + ], + ], + ], + [ + 'mwtestJsonConfigs' => [ + 'JsonZeroConfig' => [ + 'isLocal' => false, + 'remote' => [ + 'username' => 'foo', + ], + ], + ExtensionRegistry::MERGE_STRATEGY => 'array_replace_recursive', + ], + ], + [ + 'mwtestJsonConfigs' => [ + 'JsonZeroConfig' => [ + 'namespace' => 480, + 'nsName' => 'Zero', + 'isLocal' => false, + 'remote' => [ + 'username' => 'foo', + ], + ], + ], + ], + ], ]; } } diff --git a/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php b/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php index 90c9f385bb..0be04efdf3 100644 --- a/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php +++ b/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php @@ -2,11 +2,11 @@ /** * @group ResourceLoader + * @covers DerivativeResourceLoaderContext */ class DerivativeResourceLoaderContextTest extends PHPUnit_Framework_TestCase { - protected static function getResourceLoaderContext() { - $resourceLoader = new ResourceLoader(); + protected static function getContext() { $request = new FauxRequest( [ 'lang' => 'zh', 'modules' => 'test.context', @@ -14,42 +14,76 @@ class DerivativeResourceLoaderContextTest extends PHPUnit_Framework_TestCase { 'skin' => 'fallback', 'target' => 'test', ] ); - return new ResourceLoaderContext( $resourceLoader, $request ); + return new ResourceLoaderContext( new ResourceLoader(), $request ); } - public function testGet() { - $context = self::getResourceLoaderContext(); - $derived = new DerivativeResourceLoaderContext( $context ); + public function testGetInherited() { + $derived = new DerivativeResourceLoaderContext( self::getContext() ); + // Request parameters + $this->assertEquals( $derived->getDebug(), false ); $this->assertEquals( $derived->getLanguage(), 'zh' ); $this->assertEquals( $derived->getModules(), [ 'test.context' ] ); $this->assertEquals( $derived->getOnly(), 'scripts' ); $this->assertEquals( $derived->getSkin(), 'fallback' ); + $this->assertEquals( $derived->getUser(), null ); + + // Misc + $this->assertEquals( $derived->getDirection(), 'ltr' ); $this->assertEquals( $derived->getHash(), 'zh|fallback|||scripts|||||' ); } - public function testSetLanguage() { - $context = self::getResourceLoaderContext(); + public function testModules() { + $derived = new DerivativeResourceLoaderContext( self::getContext() ); + + $derived->setModules( [ 'test.override' ] ); + $this->assertEquals( $derived->getModules(), [ 'test.override' ] ); + } + + public function testLanguage() { + $context = self::getContext(); $derived = new DerivativeResourceLoaderContext( $context ); $derived->setLanguage( 'nl' ); $this->assertEquals( $derived->getLanguage(), 'nl' ); + } + + public function testDirection() { + $derived = new DerivativeResourceLoaderContext( self::getContext() ); + + $derived->setLanguage( 'nl' ); + $this->assertEquals( $derived->getDirection(), 'ltr' ); $derived->setLanguage( 'he' ); $this->assertEquals( $derived->getDirection(), 'rtl' ); + + $derived->setDirection( 'ltr' ); + $this->assertEquals( $derived->getDirection(), 'ltr' ); } - public function testSetModules() { - $context = self::getResourceLoaderContext(); - $derived = new DerivativeResourceLoaderContext( $context ); + public function testSkin() { + $derived = new DerivativeResourceLoaderContext( self::getContext() ); - $derived->setModules( [ 'test.override' ] ); - $this->assertEquals( $derived->getModules(), [ 'test.override' ] ); + $derived->setSkin( 'override' ); + $this->assertEquals( $derived->getSkin(), 'override' ); } - public function testSetOnly() { - $context = self::getResourceLoaderContext(); - $derived = new DerivativeResourceLoaderContext( $context ); + public function testUser() { + $derived = new DerivativeResourceLoaderContext( self::getContext() ); + + $derived->setUser( 'Example' ); + $this->assertEquals( $derived->getUser(), 'Example' ); + } + + public function testDebug() { + $derived = new DerivativeResourceLoaderContext( self::getContext() ); + + $derived->setDebug( true ); + $this->assertEquals( $derived->getDebug(), true ); + } + + public function testOnly() { + $derived = new DerivativeResourceLoaderContext( self::getContext() ); $derived->setOnly( 'styles' ); $this->assertEquals( $derived->getOnly(), 'styles' ); @@ -58,21 +92,35 @@ class DerivativeResourceLoaderContextTest extends PHPUnit_Framework_TestCase { $this->assertEquals( $derived->getOnly(), null ); } - public function testSetSkin() { - $context = self::getResourceLoaderContext(); - $derived = new DerivativeResourceLoaderContext( $context ); + public function testVersion() { + $derived = new DerivativeResourceLoaderContext( self::getContext() ); - $derived->setSkin( 'override' ); - $this->assertEquals( $derived->getSkin(), 'override' ); + $derived->setVersion( 'hw1' ); + $this->assertEquals( $derived->getVersion(), 'hw1' ); + } + + public function testRaw() { + $derived = new DerivativeResourceLoaderContext( self::getContext() ); + + $derived->setRaw( true ); + $this->assertEquals( $derived->getRaw(), true ); } public function testGetHash() { - $context = self::getResourceLoaderContext(); - $derived = new DerivativeResourceLoaderContext( $context ); + $derived = new DerivativeResourceLoaderContext( self::getContext() ); + + $this->assertEquals( $derived->getHash(), 'zh|fallback|||scripts|||||' ); $derived->setLanguage( 'nl' ); + $derived->setUser( 'Example' ); // Assert that subclass is able to clear parent class "hash" member - $this->assertEquals( $derived->getHash(), 'nl|fallback|||scripts|||||' ); + $this->assertEquals( $derived->getHash(), 'nl|fallback||Example|scripts|||||' ); } + public function testAccessors() { + $context = self::getContext(); + $derived = new DerivativeResourceLoaderContext( $context ); + $this->assertSame( $derived->getRequest(), $context->getRequest() ); + $this->assertSame( $derived->getResourceLoader(), $context->getResourceLoader() ); + } } diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php new file mode 100644 index 0000000000..528c3220e8 --- /dev/null +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php @@ -0,0 +1,281 @@ + ResourceLoaderTestCase::BLANK_VERSION + ] ); + } + + protected static function makeContext( $extraQuery = [] ) { + $conf = new HashConfig( [ + 'ResourceLoaderSources' => [], + 'ResourceModuleSkinStyles' => [], + 'ResourceModules' => [], + 'EnableJavaScriptTest' => false, + 'ResourceLoaderDebug' => false, + 'LoadScript' => '/w/load.php', + ] ); + return new ResourceLoaderContext( + new ResourceLoader( $conf ), + new FauxRequest( array_merge( [ + 'lang' => 'nl', + 'skin' => 'fallback', + 'user' => 'Example', + 'target' => 'phpunit', + ], $extraQuery ) ) + ); + } + + protected static function makeModule( array $options = [] ) { + return new ResourceLoaderTestModule( $options ); + } + + protected static function makeSampleModules() { + $modules = [ + 'test' => [], + 'test.top' => [ 'position' => 'top' ], + 'test.private.top' => [ 'group' => 'private', 'position' => 'top' ], + 'test.private.bottom' => [ 'group' => 'private', 'position' => 'bottom' ], + + 'test.styles.pure' => [ 'type' => ResourceLoaderModule::LOAD_STYLES ], + 'test.styles.mixed' => [], + 'test.styles.noscript' => [ 'group' => 'noscript', 'type' => ResourceLoaderModule::LOAD_STYLES ], + 'test.styles.mixed.user' => [ 'group' => 'user' ], + 'test.styles.mixed.user.empty' => [ 'group' => 'user', 'isKnownEmpty' => true ], + 'test.styles.private' => [ 'group' => 'private', 'styles' => '.private{}' ], + + 'test.scripts' => [], + 'test.scripts.top' => [ 'position' => 'top' ], + 'test.scripts.mixed.user' => [ 'group' => 'user' ], + 'test.scripts.mixed.user.empty' => [ 'group' => 'user', 'isKnownEmpty' => true ], + 'test.scripts.raw' => [ 'isRaw' => true ], + ]; + return array_map( function ( $options ) { + return self::makeModule( $options ); + }, $modules ); + } + + /** + * @covers ResourceLoaderClientHtml::getDocumentAttributes + */ + public function testGetDocumentAttributes() { + $client = new ResourceLoaderClientHtml( self::makeContext() ); + $this->assertInternalType( 'array', $client->getDocumentAttributes() ); + } + + /** + * @covers ResourceLoaderClientHtml::__construct + * @covers ResourceLoaderClientHtml::setModules + * @covers ResourceLoaderClientHtml::setModuleStyles + * @covers ResourceLoaderClientHtml::setModuleScripts + * @covers ResourceLoaderClientHtml::getData + * @covers ResourceLoaderClientHtml::getContext + */ + public function testGetData() { + $context = self::makeContext(); + $context->getResourceLoader()->register( self::makeSampleModules() ); + + $client = new ResourceLoaderClientHtml( $context ); + $client->setModules( [ + 'test', + 'test.private.bottom', + 'test.private.top', + 'test.top', + 'test.unregistered', + ] ); + $client->setModuleStyles( [ + 'test.styles.mixed', + 'test.styles.mixed.user.empty', + 'test.styles.private', + 'test.styles.pure', + 'test.unregistered.styles', + ] ); + $client->setModuleScripts( [ + 'test.scripts', + 'test.scripts.mixed.user.empty', + 'test.scripts.top', + 'test.unregistered.scripts', + ] ); + + $expected = [ + 'states' => [ + 'test.private.top' => 'loading', + 'test.private.bottom' => 'loading', + 'test.styles.pure' => 'ready', + 'test.styles.mixed.user.empty' => 'ready', + 'test.styles.private' => 'ready', + 'test.scripts' => 'loading', + 'test.scripts.top' => 'loading', + 'test.scripts.mixed.user.empty' => 'ready', + ], + 'general' => [ + 'test', + 'test.top', + ], + 'styles' => [ + 'test.styles.mixed', + 'test.styles.pure', + ], + 'scripts' => [ + 'test.scripts', + 'test.scripts.top', + ], + 'embed' => [ + 'styles' => [ 'test.styles.private' ], + 'general' => [ + 'test.private.bottom', + 'test.private.top', + ], + ], + ]; + + $access = TestingAccessWrapper::newFromObject( $client ); + $this->assertEquals( $expected, $access->getData() ); + } + + /** + * @covers ResourceLoaderClientHtml::setConfig + * @covers ResourceLoaderClientHtml::setExemptStates + * @covers ResourceLoaderClientHtml::getHeadHtml + * @covers ResourceLoaderClientHtml::getLoad + * @covers ResourceLoader::makeLoaderStateScript + */ + public function testGetHeadHtml() { + $context = self::makeContext(); + $context->getResourceLoader()->register( self::makeSampleModules() ); + + $client = new ResourceLoaderClientHtml( $context ); + $client->setConfig( [ 'key' => 'value' ] ); + $client->setModules( [ + 'test.top', + 'test.private.top', + ] ); + $client->setModuleStyles( [ + 'test.styles.pure', + 'test.styles.private', + ] ); + $client->setModuleScripts( [ + 'test.scripts.top', + ] ); + $client->setExemptStates( [ + 'test.exempt' => 'ready', + ] ); + + // @codingStandardsIgnoreStart Generic.Files.LineLength + $expected = '' . "\n" + . '' . "\n" + . '' . "\n" + . '' . "\n" + . ''; + // @codingStandardsIgnoreEnd + $expected = self::expandVariables( $expected ); + + $this->assertEquals( $expected, $client->getHeadHtml() ); + } + + /** + * @covers ResourceLoaderClientHtml::getBodyHtml + * @covers ResourceLoaderClientHtml::getLoad + */ + public function testGetBodyHtml() { + $context = self::makeContext(); + $context->getResourceLoader()->register( self::makeSampleModules() ); + + $client = new ResourceLoaderClientHtml( $context ); + $client->setConfig( [ 'key' => 'value' ] ); + $client->setModules( [ + 'test', + 'test.private.bottom', + ] ); + $client->setModuleScripts( [ + 'test.scripts', + ] ); + + $expected = ''; + $expected = self::expandVariables( $expected ); + + $this->assertEquals( $expected, $client->getBodyHtml() ); + } + + public static function provideMakeLoad() { + return [ + // @codingStandardsIgnoreStart Generic.Files.LineLength + [ + 'context' => [], + 'modules' => [ 'test.unknown' ], + 'only' => ResourceLoaderModule::TYPE_STYLES, + 'output' => '', + ], + [ + 'context' => [], + 'modules' => [ 'test.styles.private' ], + 'only' => ResourceLoaderModule::TYPE_STYLES, + 'output' => '', + ], + [ + 'context' => [], + 'modules' => [ 'test.private.top' ], + 'only' => ResourceLoaderModule::TYPE_COMBINED, + 'output' => '', + ], + [ + 'context' => [], + // Eg. startup module + 'modules' => [ 'test.scripts.raw' ], + 'only' => ResourceLoaderModule::TYPE_SCRIPTS, + 'output' => '', + ], + [ + 'context' => [], + 'modules' => [ 'test.scripts.mixed.user' ], + 'only' => ResourceLoaderModule::TYPE_SCRIPTS, + 'output' => '', + ], + [ + 'context' => [ 'debug' => true ], + 'modules' => [ 'test.styles.pure', 'test.styles.mixed' ], + 'only' => ResourceLoaderModule::TYPE_STYLES, + 'output' => '' . "\n" + . '', + ], + [ + 'context' => [], + 'modules' => [ 'test.styles.noscript' ], + 'only' => ResourceLoaderModule::TYPE_STYLES, + 'output' => '', + ], + // @codingStandardsIgnoreEnd + ]; + } + + /** + * @dataProvider provideMakeLoad + * @covers ResourceLoaderClientHtml::makeLoad + * @covers ResourceLoaderClientHtml::makeContext + * @covers ResourceLoader::makeModuleResponse + * @covers ResourceLoaderModule::getModuleContent + * @covers ResourceLoader::getCombinedVersion + * @covers ResourceLoader::createLoaderURL + * @covers ResourceLoader::createLoaderQuery + * @covers ResourceLoader::makeLoaderQuery + * @covers ResourceLoader::makeInlineScript + */ + public function testMakeLoad( array $extraQuery, array $modules, $type, $expected ) { + $context = self::makeContext( $extraQuery ); + $context->getResourceLoader()->register( self::makeSampleModules() ); + $actual = ResourceLoaderClientHtml::makeLoad( $context, $modules, $type ); + $expected = self::expandVariables( $expected ); + $this->assertEquals( $expected, (string)$actual ); + } +} diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php new file mode 100644 index 0000000000..baf0b69e6c --- /dev/null +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php @@ -0,0 +1,116 @@ + false, + 'DefaultSkin' => 'fallback', + 'LanguageCode' => 'nl', + ] ) ); + } + + public function testEmpty() { + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) ); + + // Request parameters + $this->assertEquals( [], $ctx->getModules() ); + $this->assertEquals( 'nl', $ctx->getLanguage() ); + $this->assertEquals( false, $ctx->getDebug() ); + $this->assertEquals( null, $ctx->getOnly() ); + $this->assertEquals( 'fallback', $ctx->getSkin() ); + $this->assertEquals( null, $ctx->getUser() ); + + // Misc + $this->assertEquals( 'ltr', $ctx->getDirection() ); + $this->assertEquals( 'nl|fallback||||||||', $ctx->getHash() ); + $this->assertInstanceOf( User::class, $ctx->getUserObj() ); + } + + public function testDummy() { + $this->assertInstanceOf( + ResourceLoaderContext::class, + ResourceLoaderContext::newDummyContext() + ); + } + + public function testAccessors() { + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) ); + $this->assertInstanceOf( WebRequest::class, $ctx->getRequest() ); + $this->assertInstanceOf( \Psr\Log\LoggerInterface::class, $ctx->getLogger() ); + } + + public function testTypicalRequest() { + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [ + 'debug' => 'false', + 'lang' => 'zh', + 'modules' => 'foo|foo.quux,baz,bar|baz.quux', + 'only' => 'styles', + 'skin' => 'fallback', + ] ) ); + + // Request parameters + $this->assertEquals( + $ctx->getModules(), + [ 'foo', 'foo.quux', 'foo.baz', 'foo.bar', 'baz.quux' ] + ); + $this->assertEquals( false, $ctx->getDebug() ); + $this->assertEquals( 'zh', $ctx->getLanguage() ); + $this->assertEquals( 'styles', $ctx->getOnly() ); + $this->assertEquals( 'fallback', $ctx->getSkin() ); + $this->assertEquals( null, $ctx->getUser() ); + + // Misc + $this->assertEquals( 'ltr', $ctx->getDirection() ); + $this->assertEquals( 'zh|fallback|||styles|||||', $ctx->getHash() ); + } + + public function testShouldInclude() { + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) ); + $this->assertTrue( $ctx->shouldIncludeScripts(), 'Scripts in combined' ); + $this->assertTrue( $ctx->shouldIncludeStyles(), 'Styles in combined' ); + $this->assertTrue( $ctx->shouldIncludeMessages(), 'Messages in combined' ); + + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [ + 'only' => 'styles' + ] ) ); + $this->assertFalse( $ctx->shouldIncludeScripts(), 'Scripts not in styles-only' ); + $this->assertTrue( $ctx->shouldIncludeStyles(), 'Styles in styles-only' ); + $this->assertFalse( $ctx->shouldIncludeMessages(), 'Messages not in styles-only' ); + + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [ + 'only' => 'scripts' + ] ) ); + $this->assertTrue( $ctx->shouldIncludeScripts(), 'Scripts in scripts-only' ); + $this->assertFalse( $ctx->shouldIncludeStyles(), 'Styles not in scripts-only' ); + $this->assertFalse( $ctx->shouldIncludeMessages(), 'Messages not in scripts-only' ); + } + + public function testGetUser() { + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) ); + $this->assertSame( null, $ctx->getUser() ); + $this->assertTrue( $ctx->getUserObj()->isAnon() ); + + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [ + 'user' => 'Example' + ] ) ); + $this->assertSame( 'Example', $ctx->getUser() ); + $this->assertEquals( 'Example', $ctx->getUserObj()->getName() ); + } + + public function testMsg() { + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [ + 'lang' => 'en' + ] ) ); + $msg = $ctx->msg( 'mainpage' ); + $this->assertInstanceOf( Message::class, $msg ); + $this->assertSame( 'Main Page', $msg->useDatabase( false )->plain() ); + } +} diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php index 90d8e9ff51..4a3b90a294 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php @@ -27,6 +27,15 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { return [ 'noTemplateModule' => [], + 'deprecatedModule' => $base + [ + 'deprecated' => true, + ], + 'deprecatedTomorrow' => $base + [ + 'deprecated' => [ + 'message' => 'Will be removed tomorrow.' + ], + ], + 'htmlTemplateModule' => $base + [ 'templates' => [ 'templates/template.html', @@ -93,9 +102,38 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { */ public function testTemplateDependencies( $module, $expected ) { $rl = new ResourceLoaderFileModule( $module ); + $rl->setName( 'testing' ); $this->assertEquals( $rl->getDependencies(), $expected ); } + public static function providerDeprecatedModules() { + return [ + [ + 'deprecatedModule', + 'mw.log.warn("This page is using the deprecated ResourceLoader module \"deprecatedModule\".");', + ], + [ + 'deprecatedTomorrow', + 'mw.log.warn(' . + '"This page is using the deprecated ResourceLoader module \"deprecatedTomorrow\".\\n' . + "Will be removed tomorrow." . + '");' + ] + ]; + } + + /** + * @dataProvider providerDeprecatedModules + * @covers ResourceLoaderFileModule::getScript + */ + public function testDeprecatedModules( $name, $expected ) { + $modules = self::getModules(); + $rl = new ResourceLoaderFileModule( $modules[$name] ); + $rl->setName( $name ); + $ctx = $this->getResourceLoaderContext(); + $this->assertEquals( $rl->getScript( $ctx ), $expected ); + } + /** * @covers ResourceLoaderFileModule::getAllStyleFiles * @covers ResourceLoaderFileModule::getAllSkinStyleFiles @@ -127,6 +165,7 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { ]; $module = new ResourceLoaderFileModule( $baseParams ); + $module->setName( 'testing' ); $this->assertEquals( [ @@ -164,13 +203,21 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { 'localBasePath' => $basePath, 'styles' => [ 'test.css' ], ] ); + $testModule->setName( 'testing' ); $expectedModule = new ResourceLoaderFileModule( [ 'localBasePath' => $basePath, 'styles' => [ 'expected.css' ], ] ); + $expectedModule->setName( 'testing' ); - $contextLtr = $this->getResourceLoaderContext( 'en', 'ltr' ); - $contextRtl = $this->getResourceLoaderContext( 'he', 'rtl' ); + $contextLtr = $this->getResourceLoaderContext( [ + 'lang' => 'en', + 'dir' => 'ltr', + ] ); + $contextRtl = $this->getResourceLoaderContext( [ + 'lang' => 'he', + 'dir' => 'rtl', + ] ); // Since we want to compare the effect of @noflip+@embed against the effect of just @embed, and // the @noflip annotations are always preserved, we need to strip them first. @@ -223,7 +270,29 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { */ public function testGetTemplates( $module, $expected ) { $rl = new ResourceLoaderFileModule( $module ); + $rl->setName( 'testing' ); $this->assertEquals( $rl->getTemplates(), $expected ); } + + public function testBomConcatenation() { + $basePath = __DIR__ . '/../../data/css'; + $testModule = new ResourceLoaderFileModule( [ + 'localBasePath' => $basePath, + 'styles' => [ 'bom.css' ], + ] ); + $testModule->setName( 'testing' ); + $this->assertEquals( + substr( file_get_contents( "$basePath/bom.css" ), 0, 10 ), + "\xef\xbb\xbf.efbbbf", + 'File has leading BOM' + ); + + $context = $this->getResourceLoaderContext(); + $this->assertEquals( + $testModule->getStyles( $context ), + [ 'all' => ".efbbbf_bom_char_at_start_of_file {}\n" ], + 'Leading BOM removed when concatenating files' + ); + } } diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php index f61540d8ab..aeb82d14ff 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php @@ -154,6 +154,49 @@ class ResourceLoaderImageModuleTest extends ResourceLoaderTestCase { $styles = $module->getStyles( $this->getResourceLoaderContext() ); $this->assertEquals( $expected, $styles['all'] ); } + + /** + * @covers ResourceLoaderContext::getImageObj + */ + public function testContext() { + $context = new ResourceLoaderContext( new EmptyResourceLoader(), new FauxRequest() ); + $this->assertFalse( $context->getImageObj(), 'Missing image parameter' ); + + $context = new ResourceLoaderContext( new EmptyResourceLoader(), new FauxRequest( [ + 'image' => 'example', + ] ) ); + $this->assertFalse( $context->getImageObj(), 'Missing module parameter' ); + + $context = new ResourceLoaderContext( new EmptyResourceLoader(), new FauxRequest( [ + 'modules' => 'unknown', + 'image' => 'example', + ] ) ); + $this->assertFalse( $context->getImageObj(), 'Not an image module' ); + + $rl = new EmptyResourceLoader(); + $rl->register( 'test', [ + 'class' => ResourceLoaderImageModule::class, + 'prefix' => 'test', + 'images' => [ 'example' => 'example.png' ], + ] ); + $context = new ResourceLoaderContext( $rl, new FauxRequest( [ + 'modules' => 'test', + 'image' => 'unknown', + ] ) ); + $this->assertFalse( $context->getImageObj(), 'Unknown image' ); + + $rl = new EmptyResourceLoader(); + $rl->register( 'test', [ + 'class' => ResourceLoaderImageModule::class, + 'prefix' => 'test', + 'images' => [ 'example' => 'example.png' ], + ] ); + $context = new ResourceLoaderContext( $rl, new FauxRequest( [ + 'modules' => 'test', + 'image' => 'example', + ] ) ); + $this->assertInstanceOf( ResourceLoaderImage::class, $context->getImageObj() ); + } } class ResourceLoaderImageModuleTestable extends ResourceLoaderImageModule { diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php index 179a8ed98f..84b56d4f11 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php @@ -61,7 +61,10 @@ class ResourceLoaderImageTest extends ResourceLoaderTestCase { static $contexts = []; $image = $this->getTestImage( $imageName ); - $context = $this->getResourceLoaderContext( $languageCode, $dirMap[$languageCode] ); + $context = $this->getResourceLoaderContext( [ + 'lang' => $languageCode, + 'dir' => $dirMap[$languageCode], + ] ); $this->assertEquals( $image->getPath( $context ), $this->imagesPath . '/' . $path ); } @@ -87,7 +90,7 @@ class ResourceLoaderImageTest extends ResourceLoaderTestCase { * @covers ResourceLoaderImage::massageSvgPathdata */ public function testGetImageData() { - $context = $this->getResourceLoaderContext( 'en', 'ltr' ); + $context = $this->getResourceLoaderContext(); $image = $this->getTestImage( 'remove' ); $data = file_get_contents( $this->imagesPath . '/remove.svg' ); diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php index 72ea495999..1b756be629 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php @@ -2,14 +2,9 @@ class ResourceLoaderStartUpModuleTest extends ResourceLoaderTestCase { - // Version hash for a blank file module. - // Result of ResourceLoader::makeHash(), ResourceLoaderTestModule - // and ResourceLoaderFileModule::getDefinitionSummary(). - protected static $blankVersion = 'GqV9IPpY'; - protected static function expandPlaceholders( $text ) { return strtr( $text, [ - '{blankVer}' => self::$blankVersion + '{blankVer}' => self::BLANK_VERSION ] ); } @@ -310,7 +305,6 @@ mw.loader.register( [ * @dataProvider provideGetModuleRegistrations * @covers ResourceLoaderStartUpModule::compileUnresolvedDependencies * @covers ResourceLoaderStartUpModule::getModuleRegistrations - * @covers ResourceLoader::makeLoaderSourcesScript * @covers ResourceLoader::makeLoaderRegisterScript */ public function testGetModuleRegistrations( $case ) { diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php index 65cd6edaf9..1ecdf21d92 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php @@ -17,18 +17,12 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { ] ); } - public static function provideValidModules() { - return [ - [ 'TEST.validModule1', new ResourceLoaderTestModule() ], - ]; - } - /** - * Ensures that the ResourceLoaderRegisterModules hook is called when a new - * ResourceLoader object is constructed. + * Ensure the ResourceLoaderRegisterModules hook is called. + * * @covers ResourceLoader::__construct */ - public function testCreatingNewResourceLoaderCallsRegistrationHook() { + public function testConstructRegistrationHook() { $resourceLoaderRegisterModulesHook = false; $this->setMwGlobals( 'wgHooks', [ @@ -39,66 +33,122 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { ] ] ); - $resourceLoader = new ResourceLoader(); + $unused = new ResourceLoader(); $this->assertTrue( $resourceLoaderRegisterModulesHook, 'Hook ResourceLoaderRegisterModules called' ); - - return $resourceLoader; } /** - * @dataProvider provideValidModules - * @depends testCreatingNewResourceLoaderCallsRegistrationHook * @covers ResourceLoader::register * @covers ResourceLoader::getModule */ - public function testRegisteredValidModulesAreAccessible( - $name, ResourceLoaderModule $module, ResourceLoader $resourceLoader - ) { - $resourceLoader->register( $name, $module ); - $this->assertEquals( $module, $resourceLoader->getModule( $name ) ); + public function testRegisterValid() { + $module = new ResourceLoaderTestModule(); + $resourceLoader = new EmptyResourceLoader(); + $resourceLoader->register( 'test', $module ); + $this->assertEquals( $module, $resourceLoader->getModule( 'test' ) ); } /** - * @covers ResourceLoaderFileModule::compileLessFile + * @covers ResourceLoader::register */ - public function testLessFileCompilation() { - $context = $this->getResourceLoaderContext(); - $basePath = __DIR__ . '/../../data/less/module'; - $module = new ResourceLoaderFileModule( [ - 'localBasePath' => $basePath, - 'styles' => [ 'styles.less' ], - ] ); - $module->setName( 'test.less' ); - $styles = $module->getStyles( $context ); - $this->assertStringEqualsFile( $basePath . '/styles.css', $styles['all'] ); + public function testRegisterEmptyString() { + $module = new ResourceLoaderTestModule(); + $resourceLoader = new EmptyResourceLoader(); + $resourceLoader->register( '', $module ); + $this->assertEquals( $module, $resourceLoader->getModule( '' ) ); } /** - * Strip @noflip annotations from CSS code. - * @param string $css - * @return string + * @covers ResourceLoader::register */ - private static function stripNoflip( $css ) { - return str_replace( '/*@noflip*/ ', '', $css ); + public function testRegisterInvalidName() { + $resourceLoader = new EmptyResourceLoader(); + $this->setExpectedException( 'MWException', "name 'test!invalid' is invalid" ); + $resourceLoader->register( 'test!invalid', new ResourceLoaderTestModule() ); } /** - * @dataProvider providePackedModules - * @covers ResourceLoader::makePackedModulesString + * @covers ResourceLoader::register */ - public function testMakePackedModulesString( $desc, $modules, $packed ) { - $this->assertEquals( $packed, ResourceLoader::makePackedModulesString( $modules ), $desc ); + public function testRegisterInvalidType() { + $resourceLoader = new EmptyResourceLoader(); + $this->setExpectedException( 'MWException', 'ResourceLoader module info type error' ); + $resourceLoader->register( 'test', new stdClass() ); } /** - * @dataProvider providePackedModules - * @covers ResourceLoaderContext::expandModuleNames + * @covers ResourceLoader::getModuleNames + */ + public function testGetModuleNames() { + // Use an empty one so that core and extension modules don't get in. + $resourceLoader = new EmptyResourceLoader(); + $resourceLoader->register( 'test.foo', new ResourceLoaderTestModule() ); + $resourceLoader->register( 'test.bar', new ResourceLoaderTestModule() ); + $this->assertEquals( + [ 'test.foo', 'test.bar' ], + $resourceLoader->getModuleNames() + ); + } + + /** + * @covers ResourceLoader::isModuleRegistered + */ + public function testIsModuleRegistered() { + $rl = new EmptyResourceLoader(); + $rl->register( 'test', new ResourceLoaderTestModule() ); + $this->assertTrue( $rl->isModuleRegistered( 'test' ) ); + $this->assertFalse( $rl->isModuleRegistered( 'test.unknown' ) ); + } + + /** + * @covers ResourceLoader::getModule + */ + public function testGetModuleUnknown() { + $rl = new EmptyResourceLoader(); + $this->assertSame( null, $rl->getModule( 'test' ) ); + } + + /** + * @covers ResourceLoader::getModule + */ + public function testGetModuleClass() { + $rl = new EmptyResourceLoader(); + $rl->register( 'test', [ 'class' => ResourceLoaderTestModule::class ] ); + $this->assertInstanceOf( + ResourceLoaderTestModule::class, + $rl->getModule( 'test' ) + ); + } + + /** + * @covers ResourceLoader::getModule + */ + public function testGetModuleClassDefault() { + $rl = new EmptyResourceLoader(); + $rl->register( 'test', [] ); + $this->assertInstanceOf( + ResourceLoaderFileModule::class, + $rl->getModule( 'test' ), + 'Array-style module registrations default to FileModule' + ); + } + + /** + * @covers ResourceLoaderFileModule::compileLessFile */ - public function testexpandModuleNames( $desc, $modules, $packed ) { - $this->assertEquals( $modules, ResourceLoaderContext::expandModuleNames( $packed ), $desc ); + public function testLessFileCompilation() { + $context = $this->getResourceLoaderContext(); + $basePath = __DIR__ . '/../../data/less/module'; + $module = new ResourceLoaderFileModule( [ + 'localBasePath' => $basePath, + 'styles' => [ 'styles.less' ], + ] ); + $module->setName( 'test.less' ); + $styles = $module->getStyles( $context ); + $this->assertStringEqualsFile( $basePath . '/styles.css', $styles['all'] ); } public static function providePackedModules() { @@ -123,23 +173,47 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { [ 'single.module', 'foobar', 'foobaz' ], 'single.module|foobar,foobaz', ], + [ + 'Ordering', + [ 'foo', 'foo.baz', 'baz.quux', 'foo.bar' ], + 'foo|foo.baz,bar|baz.quux', + [ 'foo', 'foo.baz', 'foo.bar', 'baz.quux' ], + ] ]; } + /** + * @dataProvider providePackedModules + * @covers ResourceLoader::makePackedModulesString + */ + public function testMakePackedModulesString( $desc, $modules, $packed ) { + $this->assertEquals( $packed, ResourceLoader::makePackedModulesString( $modules ), $desc ); + } + + /** + * @dataProvider providePackedModules + * @covers ResourceLoaderContext::expandModuleNames + */ + public function testExpandModuleNames( $desc, $modules, $packed, $unpacked = null ) { + $this->assertEquals( + $unpacked ?: $modules, + ResourceLoaderContext::expandModuleNames( $packed ), + $desc + ); + } + public static function provideAddSource() { return [ - [ 'examplewiki', '//example.org/w/load.php', 'examplewiki' ], - [ 'example2wiki', [ 'loadScript' => '//example.com/w/load.php' ], 'example2wiki' ], + [ 'foowiki', 'https://example.org/w/load.php', 'foowiki' ], + [ 'foowiki', [ 'loadScript' => 'https://example.org/w/load.php' ], 'foowiki' ], [ - [ 'foowiki' => '//foo.org/w/load.php', 'bazwiki' => '//baz.org/w/load.php' ], + [ + 'foowiki' => 'https://example.org/w/load.php', + 'bazwiki' => 'https://example.com/w/load.php', + ], null, [ 'foowiki', 'bazwiki' ] - ], - [ - [ 'foowiki' => '//foo.org/w/load.php' ], - null, - false, - ], + ] ]; } @@ -150,10 +224,6 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { */ public function testAddSource( $name, $info, $expected ) { $rl = new ResourceLoader; - if ( $expected === false ) { - $this->setExpectedException( 'MWException', 'ResourceLoader duplicate source addition error' ); - $rl->addSource( $name, $info ); - } $rl->addSource( $name, $info ); if ( is_array( $expected ) ) { foreach ( $expected as $source ) { @@ -164,17 +234,23 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { } } - public static function fakeSources() { - return [ - 'examplewiki' => [ - 'loadScript' => '//example.org/w/load.php', - 'apiScript' => '//example.org/w/api.php', - ], - 'example2wiki' => [ - 'loadScript' => '//example.com/w/load.php', - 'apiScript' => '//example.com/w/api.php', - ], - ]; + /** + * @covers ResourceLoader::addSource + */ + public function testAddSourceDupe() { + $rl = new ResourceLoader; + $this->setExpectedException( 'MWException', 'ResourceLoader duplicate source addition error' ); + $rl->addSource( 'foo', 'https://example.org/w/load.php' ); + $rl->addSource( 'foo', 'https://example.com/w/load.php' ); + } + + /** + * @covers ResourceLoader::addSource + */ + public function testAddSourceInvalid() { + $rl = new ResourceLoader; + $this->setExpectedException( 'MWException', 'with no "loadScript" key' ); + $rl->addSource( 'foo', [ 'x' => 'https://example.org/w/load.php' ] ); } public static function provideLoaderImplement() { @@ -204,8 +280,6 @@ mw.example(); 'name' => 'test.example', 'scripts' => 'mw.example();', 'styles' => [], - 'messages' => new XmlJsCode( '{}' ), - 'templates' => [], 'expected' => 'mw.loader.implement( "test.example", function ( $, jQuery, require, module ) { mw.example(); @@ -217,8 +291,6 @@ mw.example(); 'name' => 'test.example', 'scripts' => [], 'styles' => [ 'css' => [ '.mw-example {}' ] ], - 'messages' => new XmlJsCode( '{}' ), - 'templates' => [], 'expected' => 'mw.loader.implement( "test.example", [], { "css": [ @@ -231,9 +303,7 @@ mw.example(); 'name' => 'test.example', 'scripts' => 'mw.example();', - 'styles' => [], 'messages' => [ 'example' => '' ], - 'templates' => [], 'expected' => 'mw.loader.implement( "test.example", function ( $, jQuery, require, module ) { mw.example(); @@ -246,8 +316,6 @@ mw.example(); 'name' => 'test.example', 'scripts' => 'mw.example();', - 'styles' => [], - 'messages' => new XmlJsCode( '{}' ), 'templates' => [ 'example.html' => '' ], 'expected' => 'mw.loader.implement( "test.example", function ( $, jQuery, require, module ) { @@ -256,19 +324,39 @@ mw.example(); "example.html": "" } );', ] ], + [ [ + 'title' => 'Implement unwrapped user script', + + 'name' => 'user', + 'scripts' => 'mw.example( 1 );', + 'wrap' => false, + + 'expected' => 'mw.loader.implement( "user", "mw.example( 1 );" );', + ] ], ]; } /** * @dataProvider provideLoaderImplement * @covers ResourceLoader::makeLoaderImplementScript + * @covers ResourceLoader::trimArray */ public function testMakeLoaderImplementScript( $case ) { + $case += [ + 'wrap' => true, + 'styles' => [], 'templates' => [], 'messages' => new XmlJsCode( '{}' ) + ]; + ResourceLoader::clearCache(); + $this->setMwGlobals( 'wgResourceLoaderDebug', true ); + + $rl = TestingAccessWrapper::newFromClass( 'ResourceLoader' ); $this->assertEquals( $case['expected'], - ResourceLoader::makeLoaderImplementScript( + $rl->makeLoaderImplementScript( $case['name'], - $case['scripts'], + ( $case['wrap'] && is_string( $case['scripts'] ) ) + ? new XmlJsCode( $case['scripts'] ) + : $case['scripts'], $case['styles'], $case['messages'], $case['templates'] @@ -276,6 +364,64 @@ mw.example(); ); } + /** + * @covers ResourceLoader::makeLoaderImplementScript + */ + public function testMakeLoaderImplementScriptInvalid() { + $this->setExpectedException( 'MWException', 'Invalid scripts error' ); + $rl = TestingAccessWrapper::newFromClass( 'ResourceLoader' ); + $rl->makeLoaderImplementScript( + 'test', // name + 123, // scripts + null, // styles + null, // messages + null // templates + ); + } + + /** + * @covers ResourceLoader::makeLoaderSourcesScript + */ + public function testMakeLoaderSourcesScript() { + $this->assertEquals( + 'mw.loader.addSource( "local", "/w/load.php" );', + ResourceLoader::makeLoaderSourcesScript( 'local', '/w/load.php' ) + ); + $this->assertEquals( + 'mw.loader.addSource( { + "local": "/w/load.php" +} );', + ResourceLoader::makeLoaderSourcesScript( [ 'local' => '/w/load.php' ] ) + ); + $this->assertEquals( + 'mw.loader.addSource( { + "local": "/w/load.php", + "example": "https://example.org/w/load.php" +} );', + ResourceLoader::makeLoaderSourcesScript( [ + 'local' => '/w/load.php', + 'example' => 'https://example.org/w/load.php' + ] ) + ); + $this->assertEquals( + 'mw.loader.addSource( [] );', + ResourceLoader::makeLoaderSourcesScript( [] ) + ); + } + + private static function fakeSources() { + return [ + 'examplewiki' => [ + 'loadScript' => '//example.org/w/load.php', + 'apiScript' => '//example.org/w/api.php', + ], + 'example2wiki' => [ + 'loadScript' => '//example.com/w/load.php', + 'apiScript' => '//example.com/w/api.php', + ], + ]; + } + /** * @covers ResourceLoader::getLoadScript */ @@ -295,14 +441,4 @@ mw.example(); $this->assertTrue( true ); } } - - /** - * @covers ResourceLoader::isModuleRegistered - */ - public function testIsModuleRegistered() { - $rl = new ResourceLoader(); - $rl->register( 'test.module', new ResourceLoaderTestModule() ); - $this->assertTrue( $rl->isModuleRegistered( 'test.module' ) ); - $this->assertFalse( $rl->isModuleRegistered( 'test.modulenotregistered' ) ); - } } diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php index 85834d71e7..a332528ffb 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php @@ -114,28 +114,113 @@ class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase { [ [], 'test1', true ], // 'site' module with a non-empty page [ - [ 'MediaWiki:Common.js' => [ 'rev_sha1' => 'dmh6qn', 'rev_len' => 1234 ] ], + [ 'MediaWiki:Common.js' => [ 'page_len' => 1234 ] ], 'site', false, ], // 'site' module with an empty page [ - [ 'MediaWiki:Foo.js' => [ 'rev_sha1' => 'phoi', 'rev_len' => 0 ] ], + [ 'MediaWiki:Foo.js' => [ 'page_len' => 0 ] ], 'site', false, ], // 'user' module with a non-empty page [ - [ 'User:Example/common.js' => [ 'rev_sha1' => 'j7ssba', 'rev_len' => 25 ] ], + [ 'User:Example/common.js' => [ 'page_len' => 25 ] ], 'user', false, ], // 'user' module with an empty page [ - [ 'User:Example/foo.js' => [ 'rev_sha1' => 'phoi', 'rev_len' => 0 ] ], + [ 'User:Example/foo.js' => [ 'page_len' => 0 ] ], 'user', true, ], ]; } + + /** + * @covers ResourceLoaderWikiModule::getTitleInfo + */ + public function testGetTitleInfo() { + $pages = [ + 'MediaWiki:Common.css' => [ 'type' => 'styles' ], + 'mediawiki: fallback.css' => [ 'type' => 'styles' ], + ]; + $titleInfo = [ + 'MediaWiki:Common.css' => [ 'page_len' => 1234 ], + 'MediaWiki:Fallback.css' => [ 'page_len' => 0 ], + ]; + $expected = $titleInfo; + + $module = $this->getMockBuilder( 'TestResourceLoaderWikiModule' ) + ->setMethods( [ 'getPages' ] ) + ->getMock(); + $module->method( 'getPages' )->willReturn( $pages ); + // Can't mock static methods + $module::$returnFetchTitleInfo = $titleInfo; + + $context = $this->getMockBuilder( 'ResourceLoaderContext' ) + ->disableOriginalConstructor() + ->getMock(); + + $module = TestingAccessWrapper::newFromObject( $module ); + $this->assertEquals( $expected, $module->getTitleInfo( $context ), 'Title info' ); + } + + /** + * @covers ResourceLoaderWikiModule::getTitleInfo + * @covers ResourceLoaderWikiModule::setTitleInfo + * @covers ResourceLoaderWikiModule::preloadTitleInfo + */ + public function testGetPreloadedTitleInfo() { + $pages = [ + 'MediaWiki:Common.css' => [ 'type' => 'styles' ], + // Regression against T145673. It's impossible to statically declare page names in + // a canonical way since the canonical prefix is localised. As such, the preload + // cache computed the right cache key, but failed to find the results when + // doing an intersect on the canonical result, producing an empty array. + 'mediawiki: fallback.css' => [ 'type' => 'styles' ], + ]; + $titleInfo = [ + 'MediaWiki:Common.css' => [ 'page_len' => 1234 ], + 'MediaWiki:Fallback.css' => [ 'page_len' => 0 ], + ]; + $expected = $titleInfo; + + $module = $this->getMockBuilder( 'TestResourceLoaderWikiModule' ) + ->setMethods( [ 'getPages' ] ) + ->getMock(); + $module->method( 'getPages' )->willReturn( $pages ); + // Can't mock static methods + $module::$returnFetchTitleInfo = $titleInfo; + + $rl = new EmptyResourceLoader(); + $rl->register( 'testmodule', $module ); + $context = new ResourceLoaderContext( $rl, new FauxRequest() ); + + TestResourceLoaderWikiModule::invalidateModuleCache( + Title::newFromText( 'MediaWiki:Common.css' ), + null, + null, + wfWikiID() + ); + TestResourceLoaderWikiModule::preloadTitleInfo( + $context, + wfGetDB( DB_REPLICA ), + [ 'testmodule' ] + ); + + $module = TestingAccessWrapper::newFromObject( $module ); + $this->assertEquals( $expected, $module->getTitleInfo( $context ), 'Title info' ); + } +} + +class TestResourceLoaderWikiModule extends ResourceLoaderWikiModule { + public static $returnFetchTitleInfo = null; + protected static function fetchTitleInfo( IDatabase $db, array $pages, $fname = null ) { + $ret = self::$returnFetchTitleInfo; + self::$returnFetchTitleInfo = null; + return $ret; + } } diff --git a/tests/phpunit/includes/search/ParserOutputSearchDataExtractorTest.php b/tests/phpunit/includes/search/ParserOutputSearchDataExtractorTest.php new file mode 100644 index 0000000000..69d0b76f25 --- /dev/null +++ b/tests/phpunit/includes/search/ParserOutputSearchDataExtractorTest.php @@ -0,0 +1,70 @@ + 'Bar', + 'New_page' => '' + ]; + + $parserOutput = new ParserOutput( '', [], $categories ); + + $searchDataExtractor = new ParserOutputSearchDataExtractor(); + + $this->assertEquals( + [ 'Foo bar', 'New page' ], + $searchDataExtractor->getCategories( $parserOutput ) + ); + } + + public function testGetExternalLinks() { + $parserOutput = new ParserOutput(); + + $parserOutput->addExternalLink( 'https://foo' ); + $parserOutput->addExternalLink( 'https://bar' ); + + $searchDataExtractor = new ParserOutputSearchDataExtractor(); + + $this->assertEquals( + [ 'https://foo', 'https://bar' ], + $searchDataExtractor->getExternalLinks( $parserOutput ) + ); + } + + public function testGetOutgoingLinks() { + $parserOutput = new ParserOutput(); + + $parserOutput->addLink( Title::makeTitle( NS_MAIN, 'Foo_bar' ), 1 ); + $parserOutput->addLink( Title::makeTitle( NS_HELP, 'Contents' ), 2 ); + + $searchDataExtractor = new ParserOutputSearchDataExtractor(); + + // this indexes links with db key + $this->assertEquals( + [ 'Foo_bar', 'Help:Contents' ], + $searchDataExtractor->getOutgoingLinks( $parserOutput ) + ); + } + + public function testGetTemplates() { + $title = Title::makeTitle( NS_TEMPLATE, 'Cite_news' ); + + $parserOutput = new ParserOutput(); + $parserOutput->addTemplate( $title, 10, 100 ); + + $searchDataExtractor = new ParserOutputSearchDataExtractor(); + + $this->assertEquals( + [ 'Template:Cite news' ], + $searchDataExtractor->getTemplates( $parserOutput ) + ); + } + +} diff --git a/tests/phpunit/includes/search/SearchEnginePrefixTest.php b/tests/phpunit/includes/search/SearchEnginePrefixTest.php index e0de58866f..a88264bb78 100644 --- a/tests/phpunit/includes/search/SearchEnginePrefixTest.php +++ b/tests/phpunit/includes/search/SearchEnginePrefixTest.php @@ -126,11 +126,11 @@ class SearchEnginePrefixTest extends MediaWikiLangTestCase { 'results' => [ 'Special:ActiveUsers', 'Special:AllMessages', - 'Special:AllMyFiles', + 'Special:AllMyUploads', ], // Third result when testing offset 'offsetresult' => [ - 'Special:AllMyUploads', + 'Special:AllPages', ], ] ], [ [ @@ -143,7 +143,7 @@ class SearchEnginePrefixTest extends MediaWikiLangTestCase { ], // Third result when testing offset 'offsetresult' => [ - 'Special:UncategorizedImages', + 'Special:UncategorizedPages', ], ] ], [ [ diff --git a/tests/phpunit/includes/search/SearchEngineTest.php b/tests/phpunit/includes/search/SearchEngineTest.php index 055e982377..3fb4bbbb1f 100644 --- a/tests/phpunit/includes/search/SearchEngineTest.php +++ b/tests/phpunit/includes/search/SearchEngineTest.php @@ -30,7 +30,7 @@ class SearchEngineTest extends MediaWikiLangTestCase { $this->markTestSkipped( "MySQL or SQLite with FTS3 only" ); } - $searchType = $this->db->getSearchEngine(); + $searchType = SearchEngineFactory::getSearchEngineClass( $this->db ); $this->setMwGlobals( [ 'wgSearchType' => $searchType ] ); @@ -50,6 +50,10 @@ class SearchEngineTest extends MediaWikiLangTestCase { return; } + // Reset the search type back to default - some extensions may have + // overridden it. + $this->setMwGlobals( [ 'wgSearchType' => null ] ); + $this->insertPage( 'Not_Main_Page', 'This is not a main page' ); $this->insertPage( 'Talk:Not_Main_Page', @@ -153,4 +157,100 @@ class SearchEngineTest extends MediaWikiLangTestCase { "Title power search failed" ); } + /** + * @covers SearchEngine::getSearchIndexFields + */ + public function testSearchIndexFields() { + /** + * @var $mockEngine SearchEngine + */ + $mockEngine = $this->getMock( 'SearchEngine', [ 'makeSearchFieldMapping' ] ); + + $mockFieldBuilder = function ( $name, $type ) { + $mockField = + $this->getMockBuilder( 'SearchIndexFieldDefinition' )->setConstructorArgs( [ + $name, + $type + ] )->getMock(); + + $mockField->expects( $this->any() )->method( 'getMapping' )->willReturn( [ + 'testData' => 'test', + 'name' => $name, + 'type' => $type, + ] ); + + $mockField->expects( $this->any() ) + ->method( 'merge' ) + ->willReturn( $mockField ); + + return $mockField; + }; + + $mockEngine->expects( $this->atLeastOnce() ) + ->method( 'makeSearchFieldMapping' ) + ->willReturnCallback( $mockFieldBuilder ); + + // Not using mock since PHPUnit mocks do not work properly with references in params + $this->setTemporaryHook( 'SearchIndexFields', + function ( &$fields, SearchEngine $engine ) use ( $mockFieldBuilder ) { + $fields['testField'] = + $mockFieldBuilder( "testField", SearchIndexField::INDEX_TYPE_TEXT ); + return true; + } ); + + $fields = $mockEngine->getSearchIndexFields(); + $this->assertArrayHasKey( 'language', $fields ); + $this->assertArrayHasKey( 'category', $fields ); + $this->assertInstanceOf( 'SearchIndexField', $fields['testField'] ); + + $mapping = $fields['testField']->getMapping( $mockEngine ); + $this->assertArrayHasKey( 'testData', $mapping ); + $this->assertEquals( 'test', $mapping['testData'] ); + } + + public function hookSearchIndexFields( $mockFieldBuilder, &$fields, SearchEngine $engine ) { + $fields['testField'] = $mockFieldBuilder( "testField", SearchIndexField::INDEX_TYPE_TEXT ); + return true; + } + + public function testAugmentorSearch() { + $this->search->setNamespaces( [ 0, 1, 4 ] ); + $resultSet = $this->search->searchText( 'smithee' ); + // Not using mock since PHPUnit mocks do not work properly with references in params + $this->mergeMwGlobalArrayValue( 'wgHooks', + [ 'SearchResultsAugment' => [ [ $this, 'addAugmentors' ] ] ] ); + $this->search->augmentSearchResults( $resultSet ); + for ( $result = $resultSet->next(); $result; $result = $resultSet->next() ) { + $id = $result->getTitle()->getArticleID(); + $augmentData = "Result:$id:" . $result->getTitle()->getText(); + $augmentData2 = "Result2:$id:" . $result->getTitle()->getText(); + $this->assertEquals( [ 'testSet' => $augmentData, 'testRow' => $augmentData2 ], + $result->getExtensionData() ); + } + } + + public function addAugmentors( &$setAugmentors, &$rowAugmentors ) { + $setAugmentor = $this->getMock( 'ResultSetAugmentor' ); + $setAugmentor->expects( $this->once() ) + ->method( 'augmentAll' ) + ->willReturnCallback( function ( SearchResultSet $resultSet ) { + $data = []; + for ( $result = $resultSet->next(); $result; $result = $resultSet->next() ) { + $id = $result->getTitle()->getArticleID(); + $data[$id] = "Result:$id:" . $result->getTitle()->getText(); + } + $resultSet->rewind(); + return $data; + } ); + $setAugmentors['testSet'] = $setAugmentor; + + $rowAugmentor = $this->getMock( 'ResultAugmentor' ); + $rowAugmentor->expects( $this->exactly( 2 ) ) + ->method( 'augment' ) + ->willReturnCallback( function ( SearchResult $result ) { + $id = $result->getTitle()->getArticleID(); + return "Result2:$id:" . $result->getTitle()->getText(); + } ); + $rowAugmentors['testRow'] = $rowAugmentor; + } } diff --git a/tests/phpunit/includes/search/SearchIndexFieldTest.php b/tests/phpunit/includes/search/SearchIndexFieldTest.php new file mode 100644 index 0000000000..ec046a767f --- /dev/null +++ b/tests/phpunit/includes/search/SearchIndexFieldTest.php @@ -0,0 +1,39 @@ +getMockBuilder( 'SearchIndexFieldDefinition' ) + ->setMethods( [ 'getMapping' ] ) + ->setConstructorArgs( [ $n1, $t1 ] )->getMock(); + $field2 = $this->getMockBuilder( 'SearchIndexFieldDefinition' ) + ->setMethods( [ 'getMapping' ] ) + ->setConstructorArgs( [ $n2, $t2 ] )->getMock(); + + if ( $result ) { + $this->assertNotFalse( $field1->merge( $field2 ) ); + } else { + $this->assertFalse( $field1->merge( $field2 ) ); + } + + $field1->setFlag( 0xFF ); + $this->assertFalse( $field1->merge( $field2 ) ); + } +} diff --git a/tests/phpunit/includes/session/BotPasswordSessionProviderTest.php b/tests/phpunit/includes/session/BotPasswordSessionProviderTest.php index edab0dcf4a..9bc41c06b3 100644 --- a/tests/phpunit/includes/session/BotPasswordSessionProviderTest.php +++ b/tests/phpunit/includes/session/BotPasswordSessionProviderTest.php @@ -65,11 +65,10 @@ class BotPasswordSessionProviderTest extends MediaWikiTestCase { public function addDBDataOnce() { $passwordFactory = new \PasswordFactory(); $passwordFactory->init( \RequestContext::getMain()->getConfig() ); - // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only - $passwordFactory->setDefaultType( 'A' ); - $pwhash = $passwordFactory->newFromPlaintext( 'foobaz' ); + $passwordHash = $passwordFactory->newFromPlaintext( 'foobaz' ); - $userId = \CentralIdLookup::factory( 'local' )->centralIdFromName( 'UTSysop' ); + $sysop = static::getTestSysop()->getUser(); + $userId = \CentralIdLookup::factory( 'local' )->centralIdFromName( $sysop->getName() ); $dbw = wfGetDB( DB_MASTER ); $dbw->delete( @@ -82,7 +81,7 @@ class BotPasswordSessionProviderTest extends MediaWikiTestCase { [ 'bp_user' => $userId, 'bp_app_id' => 'BotPasswordSessionProvider', - 'bp_password' => $pwhash->toString(), + 'bp_password' => $passwordHash->toString(), 'bp_token' => 'token!', 'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}', 'bp_grants' => '["test"]', @@ -184,7 +183,7 @@ class BotPasswordSessionProviderTest extends MediaWikiTestCase { public function testNewSessionInfoForRequest() { $provider = $this->getProvider(); - $user = \User::newFromName( 'UTSysop' ); + $user = static::getTestSysop()->getUser(); $request = $this->getMock( 'FauxRequest', [ 'getIP' ] ); $request->expects( $this->any() )->method( 'getIP' ) ->will( $this->returnValue( '127.0.0.1' ) ); @@ -211,7 +210,7 @@ class BotPasswordSessionProviderTest extends MediaWikiTestCase { $provider = $this->getProvider(); $provider->setLogger( $logger ); - $user = \User::newFromName( 'UTSysop' ); + $user = static::getTestSysop()->getUser(); $request = $this->getMock( 'FauxRequest', [ 'getIP' ] ); $request->expects( $this->any() )->method( 'getIP' ) ->will( $this->returnValue( '127.0.0.1' ) ); diff --git a/tests/phpunit/includes/session/CookieSessionProviderTest.php b/tests/phpunit/includes/session/CookieSessionProviderTest.php index 70e89d4b0f..da4b06ec83 100644 --- a/tests/phpunit/includes/session/CookieSessionProviderTest.php +++ b/tests/phpunit/includes/session/CookieSessionProviderTest.php @@ -14,7 +14,6 @@ use Psr\Log\LogLevel; class CookieSessionProviderTest extends MediaWikiTestCase { private function getConfig() { - global $wgCookieExpiration; return new \HashConfig( [ 'CookiePrefix' => 'CookiePrefix', 'CookiePath' => 'CookiePath', @@ -22,8 +21,8 @@ class CookieSessionProviderTest extends MediaWikiTestCase { 'CookieSecure' => true, 'CookieHttpOnly' => true, 'SessionName' => false, - 'ExtendedLoginCookies' => [ 'UserID', 'Token' ], - 'ExtendedLoginCookieExpiration' => $wgCookieExpiration * 2, + 'CookieExpiration' => 100, + 'ExtendedLoginCookieExpiration' => 200, ] ); } @@ -148,6 +147,14 @@ class CookieSessionProviderTest extends MediaWikiTestCase { $this->assertTrue( $provider->persistsSessionId() ); $this->assertTrue( $provider->canChangeUser() ); + $extendedCookies = [ 'UserID', 'UserName', 'Token' ]; + + $this->assertEquals( + $extendedCookies, + \TestingAccessWrapper::newFromObject( $provider )->getExtendedLoginCookies(), + 'List of extended cookies (subclasses can add values, but we\'re calling the core one here)' + ); + $msg = $provider->whyNoSession(); $this->assertInstanceOf( 'Message', $msg ); $this->assertSame( 'sessionprovider-nocookies', $msg->getKey() ); @@ -165,7 +172,7 @@ class CookieSessionProviderTest extends MediaWikiTestCase { $provider->setConfig( $this->getConfig() ); $provider->setManager( new SessionManager() ); - $user = User::newFromName( 'UTSysop' ); + $user = static::getTestSysop()->getUser(); $id = $user->getId(); $name = $user->getName(); $token = $user->getToken( true ); @@ -377,8 +384,6 @@ class CookieSessionProviderTest extends MediaWikiTestCase { } public function testPersistSession() { - $this->setMwGlobals( [ 'wgCookieExpiration' => 100 ] ); - $provider = new CookieSessionProvider( [ 'priority' => 1, 'sessionName' => 'MySessionName', @@ -392,7 +397,7 @@ class CookieSessionProviderTest extends MediaWikiTestCase { $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; $store = new TestBagOStuff(); - $user = User::newFromName( 'UTSysop' ); + $user = static::getTestSysop()->getUser(); $anon = new User; $backend = new SessionBackend( @@ -461,7 +466,6 @@ class CookieSessionProviderTest extends MediaWikiTestCase { */ public function testCookieData( $secure, $remember ) { $this->setMwGlobals( [ - 'wgCookieExpiration' => 100, 'wgSecureLogin' => false, ] ); @@ -478,7 +482,7 @@ class CookieSessionProviderTest extends MediaWikiTestCase { $provider->setManager( SessionManager::singleton() ); $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; - $user = User::newFromName( 'UTSysop' ); + $user = static::getTestSysop()->getUser(); $this->assertFalse( $user->requiresHTTPS(), 'sanity check' ); $backend = new SessionBackend( @@ -509,10 +513,10 @@ class CookieSessionProviderTest extends MediaWikiTestCase { 'httpOnly' => $config->get( 'CookieHttpOnly' ), 'raw' => false, ]; + + $normalExpiry = $config->get( 'CookieExpiration' ); $extendedExpiry = $config->get( 'ExtendedLoginCookieExpiration' ); $extendedExpiry = (int)( $extendedExpiry === null ? 0 : $extendedExpiry ); - $this->assertEquals( [ 'UserID', 'Token' ], $config->get( 'ExtendedLoginCookies' ), - 'sanity check' ); $expect = [ 'MySessionName' => [ 'value' => (string)$sessionId, @@ -520,10 +524,11 @@ class CookieSessionProviderTest extends MediaWikiTestCase { ] + $defaults, 'xUserID' => [ 'value' => (string)$user->getId(), - 'expire' => $extendedExpiry, + 'expire' => $remember ? $extendedExpiry : $normalExpiry, ] + $defaults, 'xUserName' => [ 'value' => $user->getName(), + 'expire' => $remember ? $extendedExpiry : $normalExpiry ] + $defaults, 'xToken' => [ 'value' => $remember ? $user->getToken() : '', @@ -580,7 +585,7 @@ class CookieSessionProviderTest extends MediaWikiTestCase { $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; $store = new TestBagOStuff(); - $user = User::newFromName( 'UTSysop' ); + $user = static::getTestSysop()->getUser(); $anon = new User; $backend = new SessionBackend( @@ -783,4 +788,47 @@ class CookieSessionProviderTest extends MediaWikiTestCase { $this->assertNull( $provider->getCookie( $request, 'Baz', 'x' ) ); } + public function testGetRememberUserDuration() { + $config = $this->getConfig(); + $provider = new CookieSessionProvider( [ 'priority' => 10 ] ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( $config ); + $provider->setManager( SessionManager::singleton() ); + + $this->assertSame( 200, $provider->getRememberUserDuration() ); + + $config->set( 'ExtendedLoginCookieExpiration', null ); + + $this->assertSame( 100, $provider->getRememberUserDuration() ); + + $config->set( 'ExtendedLoginCookieExpiration', 0 ); + + $this->assertSame( null, $provider->getRememberUserDuration() ); + } + + public function testGetLoginCookieExpiration() { + $config = $this->getConfig(); + $provider = \TestingAccessWrapper::newFromObject( new CookieSessionProvider( [ + 'priority' => 10 + ] ) ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( $config ); + $provider->setManager( SessionManager::singleton() ); + + // First cookie is an extended cookie, remember me true + $this->assertSame( 200, $provider->getLoginCookieExpiration( 'Token', true ) ); + $this->assertSame( 100, $provider->getLoginCookieExpiration( 'User', true ) ); + + // First cookie is an extended cookie, remember me false + $this->assertSame( 100, $provider->getLoginCookieExpiration( 'UserID', false ) ); + $this->assertSame( 100, $provider->getLoginCookieExpiration( 'User', false ) ); + + $config->set( 'ExtendedLoginCookieExpiration', null ); + + $this->assertSame( 100, $provider->getLoginCookieExpiration( 'Token', true ) ); + $this->assertSame( 100, $provider->getLoginCookieExpiration( 'User', true ) ); + + $this->assertSame( 100, $provider->getLoginCookieExpiration( 'Token', false ) ); + $this->assertSame( 100, $provider->getLoginCookieExpiration( 'User', false ) ); + } } diff --git a/tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php b/tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php index d705fc0191..78edb7671e 100644 --- a/tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php +++ b/tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php @@ -249,7 +249,7 @@ class ImmutableSessionProviderWithCookieTest extends MediaWikiTestCase { } $this->assertEquals( [ 'value' => 'true', - 'expire' => $remember ? 100 : null, + 'expire' => null, 'path' => 'CookiePath', 'domain' => 'CookieDomain', 'secure' => false, diff --git a/tests/phpunit/includes/session/PHPSessionHandlerTest.php b/tests/phpunit/includes/session/PHPSessionHandlerTest.php index ce0f1b0611..34e5e449a3 100644 --- a/tests/phpunit/includes/session/PHPSessionHandlerTest.php +++ b/tests/phpunit/includes/session/PHPSessionHandlerTest.php @@ -21,7 +21,7 @@ class PHPSessionHandlerTest extends MediaWikiTestCase { } return false; } ); - $reset[] = new \ScopedCallback( 'restore_error_handler' ); + $reset[] = new \Wikimedia\ScopedCallback( 'restore_error_handler' ); $rProp = new \ReflectionProperty( PHPSessionHandler::class, 'instance' ); $rProp->setAccessible( true ); @@ -30,7 +30,7 @@ class PHPSessionHandlerTest extends MediaWikiTestCase { $oldManager = $old->manager; $oldStore = $old->store; $oldLogger = $old->logger; - $reset[] = new \ScopedCallback( + $reset[] = new \Wikimedia\ScopedCallback( [ PHPSessionHandler::class, 'install' ], [ $oldManager, $oldStore, $oldLogger ] ); @@ -49,7 +49,7 @@ class PHPSessionHandlerTest extends MediaWikiTestCase { $rProp = new \ReflectionProperty( PHPSessionHandler::class, 'instance' ); $rProp->setAccessible( true ); - $reset = new \ScopedCallback( [ $rProp, 'setValue' ], [ $rProp->getValue() ] ); + $reset = new \Wikimedia\ScopedCallback( [ $rProp, 'setValue' ], [ $rProp->getValue() ] ); $rProp->setValue( $handler ); $handler->setEnableFlags( 'enable' ); @@ -123,7 +123,7 @@ class PHPSessionHandlerTest extends MediaWikiTestCase { ] ); PHPSessionHandler::install( $manager ); $wrap = \TestingAccessWrapper::newFromObject( $rProp->getValue() ); - $reset[] = new \ScopedCallback( + $reset[] = new \Wikimedia\ScopedCallback( [ $wrap, 'setEnableFlags' ], [ $wrap->enable ? $wrap->warn ? 'warn' : 'enable' : 'disable' ] ); @@ -173,14 +173,6 @@ class PHPSessionHandlerTest extends MediaWikiTestCase { $this->assertSame( $expect, $_SESSION ); } - // Test expiry - session_write_close(); - ini_set( 'session.gc_divisor', 1 ); - ini_set( 'session.gc_probability', 1 ); - sleep( 3 ); - session_start(); - $this->assertSame( [], $_SESSION ); - // Re-fill the session, then test that session_destroy() works. $_SESSION['AuthenticationSessionTest'] = $rand; session_write_close(); @@ -334,7 +326,7 @@ class PHPSessionHandlerTest extends MediaWikiTestCase { \TestingAccessWrapper::newFromObject( $handler )->setEnableFlags( 'disable' ); $oldValue = $rProp->getValue(); $rProp->setValue( $handler ); - $reset = new \ScopedCallback( [ $rProp, 'setValue' ], [ $oldValue ] ); + $reset = new \Wikimedia\ScopedCallback( [ $rProp, 'setValue' ], [ $oldValue ] ); call_user_func_array( [ $handler, $method ], $args ); } diff --git a/tests/phpunit/includes/session/SessionBackendTest.php b/tests/phpunit/includes/session/SessionBackendTest.php index 0b5f4c2f8f..8a0adbad76 100644 --- a/tests/phpunit/includes/session/SessionBackendTest.php +++ b/tests/phpunit/includes/session/SessionBackendTest.php @@ -360,7 +360,7 @@ class SessionBackendTest extends MediaWikiTestCase { } public function testSetUser() { - $user = User::newFromName( 'UTSysop' ); + $user = static::getTestSysop()->getUser(); $this->provider = $this->getMock( 'DummySessionProvider', [ 'canChangeUser' ] ); $this->provider->expects( $this->any() )->method( 'canChangeUser' ) @@ -464,7 +464,7 @@ class SessionBackendTest extends MediaWikiTestCase { // Save happens when delay is consumed $this->onSessionMetadataCalled = false; $priv->metaDirty = true; - \ScopedCallback::consume( $delay ); + \Wikimedia\ScopedCallback::consume( $delay ); $this->assertTrue( $this->onSessionMetadataCalled ); // Test multiple delays @@ -475,16 +475,16 @@ class SessionBackendTest extends MediaWikiTestCase { $priv->metaDirty = true; $priv->autosave(); $this->assertFalse( $this->onSessionMetadataCalled ); - \ScopedCallback::consume( $delay3 ); + \Wikimedia\ScopedCallback::consume( $delay3 ); $this->assertFalse( $this->onSessionMetadataCalled ); - \ScopedCallback::consume( $delay1 ); + \Wikimedia\ScopedCallback::consume( $delay1 ); $this->assertFalse( $this->onSessionMetadataCalled ); - \ScopedCallback::consume( $delay2 ); + \Wikimedia\ScopedCallback::consume( $delay2 ); $this->assertTrue( $this->onSessionMetadataCalled ); } public function testSave() { - $user = User::newFromName( 'UTSysop' ); + $user = static::getTestSysop()->getUser(); $this->store = new TestBagOStuff(); $testData = [ 'foo' => 'foo!', 'bar', [ 'baz', null ] ]; @@ -733,7 +733,7 @@ class SessionBackendTest extends MediaWikiTestCase { } public function testRenew() { - $user = User::newFromName( 'UTSysop' ); + $user = static::getTestSysop()->getUser(); $this->store = new TestBagOStuff(); $testData = [ 'foo' => 'foo!', 'bar', [ 'baz', null ] ]; @@ -822,14 +822,14 @@ class SessionBackendTest extends MediaWikiTestCase { $rProp = new \ReflectionProperty( PHPSessionHandler::class, 'instance' ); $rProp->setAccessible( true ); $handler = \TestingAccessWrapper::newFromObject( $rProp->getValue() ); - $resetHandler = new \ScopedCallback( function () use ( $handler ) { + $resetHandler = new \Wikimedia\ScopedCallback( function () use ( $handler ) { session_write_close(); $handler->enable = false; } ); $handler->enable = true; } - $backend = $this->getBackend( User::newFromName( 'UTSysop' ) ); + $backend = $this->getBackend( static::getTestSysop()->getUser() ); \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = true; $resetSingleton = TestUtils::setSessionManagerSingleton( $this->manager ); @@ -862,7 +862,7 @@ class SessionBackendTest extends MediaWikiTestCase { $rProp = new \ReflectionProperty( PHPSessionHandler::class, 'instance' ); $rProp->setAccessible( true ); $handler = \TestingAccessWrapper::newFromObject( $rProp->getValue() ); - $resetHandler = new \ScopedCallback( function () use ( $handler ) { + $resetHandler = new \Wikimedia\ScopedCallback( function () use ( $handler ) { session_write_close(); $handler->enable = false; } ); @@ -898,7 +898,7 @@ class SessionBackendTest extends MediaWikiTestCase { $rProp = new \ReflectionProperty( PHPSessionHandler::class, 'instance' ); $rProp->setAccessible( true ); $handler = \TestingAccessWrapper::newFromObject( $rProp->getValue() ); - $resetHandler = new \ScopedCallback( function () use ( $handler ) { + $resetHandler = new \Wikimedia\ScopedCallback( function () use ( $handler ) { session_write_close(); $handler->enable = false; } ); diff --git a/tests/phpunit/includes/session/SessionInfoTest.php b/tests/phpunit/includes/session/SessionInfoTest.php index ff22bfad42..8f7b2a6e70 100644 --- a/tests/phpunit/includes/session/SessionInfoTest.php +++ b/tests/phpunit/includes/session/SessionInfoTest.php @@ -103,6 +103,7 @@ class SessionInfoTest extends MediaWikiTestCase { $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); $this->assertSame( $anonInfo, $info->getUserInfo() ); $this->assertTrue( $info->isIdSafe() ); + $this->assertFalse( $info->forceUse() ); $this->assertFalse( $info->wasPersisted() ); $this->assertFalse( $info->wasRemembered() ); $this->assertFalse( $info->forceHTTPS() ); @@ -118,6 +119,7 @@ class SessionInfoTest extends MediaWikiTestCase { $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() ); $this->assertTrue( $info->isIdSafe() ); + $this->assertFalse( $info->forceUse() ); $this->assertFalse( $info->wasPersisted() ); $this->assertFalse( $info->wasRemembered() ); $this->assertFalse( $info->forceHTTPS() ); @@ -132,6 +134,7 @@ class SessionInfoTest extends MediaWikiTestCase { $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); $this->assertSame( $userInfo, $info->getUserInfo() ); $this->assertTrue( $info->isIdSafe() ); + $this->assertFalse( $info->forceUse() ); $this->assertFalse( $info->wasPersisted() ); $this->assertTrue( $info->wasRemembered() ); $this->assertFalse( $info->forceHTTPS() ); @@ -150,6 +153,7 @@ class SessionInfoTest extends MediaWikiTestCase { $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); $this->assertSame( $anonInfo, $info->getUserInfo() ); $this->assertFalse( $info->isIdSafe() ); + $this->assertFalse( $info->forceUse() ); $this->assertTrue( $info->wasPersisted() ); $this->assertFalse( $info->wasRemembered() ); $this->assertFalse( $info->forceHTTPS() ); @@ -165,6 +169,7 @@ class SessionInfoTest extends MediaWikiTestCase { $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); $this->assertSame( $userInfo, $info->getUserInfo() ); $this->assertFalse( $info->isIdSafe() ); + $this->assertFalse( $info->forceUse() ); $this->assertFalse( $info->wasPersisted() ); $this->assertTrue( $info->wasRemembered() ); $this->assertFalse( $info->forceHTTPS() ); @@ -180,6 +185,7 @@ class SessionInfoTest extends MediaWikiTestCase { $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); $this->assertSame( $userInfo, $info->getUserInfo() ); $this->assertFalse( $info->isIdSafe() ); + $this->assertFalse( $info->forceUse() ); $this->assertTrue( $info->wasPersisted() ); $this->assertFalse( $info->wasRemembered() ); $this->assertFalse( $info->forceHTTPS() ); @@ -231,6 +237,25 @@ class SessionInfoTest extends MediaWikiTestCase { $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); $this->assertTrue( $info->isIdSafe() ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [ + 'id' => $id, + 'forceUse' => true, + ] ); + $this->assertFalse( $info->forceUse(), 'no provider' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [ + 'provider' => $provider, + 'forceUse' => true, + ] ); + $this->assertFalse( $info->forceUse(), 'no id' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [ + 'provider' => $provider, + 'id' => $id, + 'forceUse' => true, + ] ); + $this->assertTrue( $info->forceUse(), 'correct use' ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [ 'id' => $id, 'forceHTTPS' => 1, @@ -242,6 +267,7 @@ class SessionInfoTest extends MediaWikiTestCase { 'provider' => $provider, 'userInfo' => $userInfo, 'idIsSafe' => true, + 'forceUse' => true, 'persisted' => true, 'remembered' => true, 'forceHTTPS' => true, @@ -255,6 +281,7 @@ class SessionInfoTest extends MediaWikiTestCase { $this->assertSame( $provider, $info->getProvider() ); $this->assertSame( $userInfo, $info->getUserInfo() ); $this->assertTrue( $info->isIdSafe() ); + $this->assertTrue( $info->forceUse() ); $this->assertTrue( $info->wasPersisted() ); $this->assertTrue( $info->wasRemembered() ); $this->assertTrue( $info->forceHTTPS() ); @@ -265,6 +292,7 @@ class SessionInfoTest extends MediaWikiTestCase { 'provider' => $provider2, 'userInfo' => $unverifiedUserInfo, 'idIsSafe' => false, + 'forceUse' => false, 'persisted' => false, 'remembered' => false, 'forceHTTPS' => false, @@ -276,6 +304,7 @@ class SessionInfoTest extends MediaWikiTestCase { $this->assertSame( $provider2, $info->getProvider() ); $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() ); $this->assertFalse( $info->isIdSafe() ); + $this->assertFalse( $info->forceUse() ); $this->assertFalse( $info->wasPersisted() ); $this->assertFalse( $info->wasRemembered() ); $this->assertFalse( $info->forceHTTPS() ); diff --git a/tests/phpunit/includes/session/SessionManagerTest.php b/tests/phpunit/includes/session/SessionManagerTest.php index d04d7ec46d..6273f477c8 100644 --- a/tests/phpunit/includes/session/SessionManagerTest.php +++ b/tests/phpunit/includes/session/SessionManagerTest.php @@ -62,7 +62,7 @@ class SessionManagerTest extends MediaWikiTestCase { $rProp->setAccessible( true ); $handler = \TestingAccessWrapper::newFromObject( $rProp->getValue() ); $oldEnable = $handler->enable; - $reset[] = new \ScopedCallback( function () use ( $handler, $oldEnable ) { + $reset[] = new \Wikimedia\ScopedCallback( function () use ( $handler, $oldEnable ) { if ( $handler->enable ) { session_write_close(); } @@ -642,6 +642,35 @@ class SessionManagerTest extends MediaWikiTestCase { } } + public function testInvalidateSessionsForUser() { + $user = User::newFromName( 'UTSysop' ); + $manager = $this->getManager(); + + $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' ) + ->setMethods( [ 'invalidateSessionsForUser', '__toString' ] ); + + $provider1 = $providerBuilder->getMock(); + $provider1->expects( $this->once() )->method( 'invalidateSessionsForUser' ) + ->with( $this->identicalTo( $user ) ); + $provider1->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockProvider1' ) ); + + $provider2 = $providerBuilder->getMock(); + $provider2->expects( $this->once() )->method( 'invalidateSessionsForUser' ) + ->with( $this->identicalTo( $user ) ); + $provider2->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockProvider2' ) ); + + $this->config->set( 'SessionProviders', [ + $this->objectCacheDef( $provider1 ), + $this->objectCacheDef( $provider2 ), + ] ); + + $oldToken = $user->getToken( true ); + $manager->invalidateSessionsForUser( $user ); + $this->assertNotEquals( $oldToken, $user->getToken() ); + } + public function testGetVaryHeaders() { $manager = $this->getManager(); @@ -838,299 +867,6 @@ class SessionManagerTest extends MediaWikiTestCase { $this->assertTrue( SessionManager::validateSessionId( $id ), "Generated ID: $id" ); } - public function testAutoCreateUser() { - global $wgGroupPermissions; - - \ObjectCache::$instances[__METHOD__] = new TestBagOStuff(); - $this->setMwGlobals( [ 'wgMainCacheType' => __METHOD__ ] ); - $this->setMwGlobals( [ - 'wgAuth' => new AuthPlugin, - ] ); - - $this->stashMwGlobals( [ 'wgGroupPermissions' ] ); - $wgGroupPermissions['*']['createaccount'] = true; - $wgGroupPermissions['*']['autocreateaccount'] = false; - - // Replace the global singleton with one configured for testing - $manager = $this->getManager(); - $reset = TestUtils::setSessionManagerSingleton( $manager ); - - $logger = new \TestLogger( true, function ( $m ) { - if ( substr( $m, 0, 15 ) === 'SessionBackend ' ) { - // Don't care. - return null; - } - $m = str_replace( 'MediaWiki\Session\SessionManager::autoCreateUser: ', '', $m ); - return $m; - } ); - $manager->setLogger( $logger ); - - $session = SessionManager::getGlobalSession(); - - // Can't create an already-existing user - $user = User::newFromName( 'UTSysop' ); - $id = $user->getId(); - $this->assertFalse( $manager->autoCreateUser( $user ) ); - $this->assertSame( $id, $user->getId() ); - $this->assertSame( 'UTSysop', $user->getName() ); - $this->assertSame( [], $logger->getBuffer() ); - $logger->clearBuffer(); - - // Sanity check that creation works at all - $user = User::newFromName( 'UTSessionAutoCreate1' ); - $this->assertSame( 0, $user->getId(), 'sanity check' ); - $this->assertTrue( $manager->autoCreateUser( $user ) ); - $this->assertNotEquals( 0, $user->getId() ); - $this->assertSame( 'UTSessionAutoCreate1', $user->getName() ); - $this->assertEquals( - $user->getId(), User::idFromName( 'UTSessionAutoCreate1', User::READ_LATEST ) - ); - $this->assertSame( [ - [ LogLevel::INFO, 'creating new user ({username}) - from: {url}' ], - ], $logger->getBuffer() ); - $logger->clearBuffer(); - - // Check lack of permissions - $wgGroupPermissions['*']['createaccount'] = false; - $wgGroupPermissions['*']['autocreateaccount'] = false; - $user = User::newFromName( 'UTDoesNotExist' ); - $this->assertFalse( $manager->autoCreateUser( $user ) ); - $this->assertSame( 0, $user->getId() ); - $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); - $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); - $session->clear(); - $this->assertSame( [ - [ - LogLevel::DEBUG, - 'user is blocked from this wiki, blacklisting', - ], - ], $logger->getBuffer() ); - $logger->clearBuffer(); - - // Check other permission - $wgGroupPermissions['*']['createaccount'] = false; - $wgGroupPermissions['*']['autocreateaccount'] = true; - $user = User::newFromName( 'UTSessionAutoCreate2' ); - $this->assertSame( 0, $user->getId(), 'sanity check' ); - $this->assertTrue( $manager->autoCreateUser( $user ) ); - $this->assertNotEquals( 0, $user->getId() ); - $this->assertSame( 'UTSessionAutoCreate2', $user->getName() ); - $this->assertEquals( - $user->getId(), User::idFromName( 'UTSessionAutoCreate2', User::READ_LATEST ) - ); - $this->assertSame( [ - [ LogLevel::INFO, 'creating new user ({username}) - from: {url}' ], - ], $logger->getBuffer() ); - $logger->clearBuffer(); - - // Test account-creation block - $anon = new User; - $block = new \Block( [ - 'address' => $anon->getName(), - 'user' => $id, - 'reason' => __METHOD__, - 'expiry' => time() + 100500, - 'createAccount' => true, - ] ); - $block->insert(); - $this->assertInstanceOf( 'Block', $anon->isBlockedFromCreateAccount(), 'sanity check' ); - $reset2 = new \ScopedCallback( [ $block, 'delete' ] ); - $user = User::newFromName( 'UTDoesNotExist' ); - $this->assertFalse( $manager->autoCreateUser( $user ) ); - $this->assertSame( 0, $user->getId() ); - $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); - $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); - \ScopedCallback::consume( $reset2 ); - $session->clear(); - $this->assertSame( [ - [ LogLevel::DEBUG, 'user is blocked from this wiki, blacklisting' ], - ], $logger->getBuffer() ); - $logger->clearBuffer(); - - // Sanity check that creation still works - $user = User::newFromName( 'UTSessionAutoCreate3' ); - $this->assertSame( 0, $user->getId(), 'sanity check' ); - $this->assertTrue( $manager->autoCreateUser( $user ) ); - $this->assertNotEquals( 0, $user->getId() ); - $this->assertSame( 'UTSessionAutoCreate3', $user->getName() ); - $this->assertEquals( - $user->getId(), User::idFromName( 'UTSessionAutoCreate3', User::READ_LATEST ) - ); - $this->assertSame( [ - [ LogLevel::INFO, 'creating new user ({username}) - from: {url}' ], - ], $logger->getBuffer() ); - $logger->clearBuffer(); - - // Test prevention by AuthPlugin - global $wgAuth; - $oldWgAuth = $wgAuth; - $mockWgAuth = $this->getMock( 'AuthPlugin', [ 'autoCreate' ] ); - $mockWgAuth->expects( $this->once() )->method( 'autoCreate' ) - ->will( $this->returnValue( false ) ); - $this->setMwGlobals( [ - 'wgAuth' => $mockWgAuth, - ] ); - $user = User::newFromName( 'UTDoesNotExist' ); - $this->assertFalse( $manager->autoCreateUser( $user ) ); - $this->assertSame( 0, $user->getId() ); - $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); - $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); - $this->setMwGlobals( [ - 'wgAuth' => $oldWgAuth, - ] ); - $session->clear(); - $this->assertSame( [ - [ LogLevel::DEBUG, 'denied by AuthPlugin' ], - ], $logger->getBuffer() ); - $logger->clearBuffer(); - - // Test prevention by wfReadOnly() - $this->setMwGlobals( [ - 'wgReadOnly' => 'Because', - ] ); - $user = User::newFromName( 'UTDoesNotExist' ); - $this->assertFalse( $manager->autoCreateUser( $user ) ); - $this->assertSame( 0, $user->getId() ); - $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); - $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); - $this->setMwGlobals( [ - 'wgReadOnly' => false, - ] ); - $session->clear(); - $this->assertSame( [ - [ LogLevel::DEBUG, 'denied by wfReadOnly()' ], - ], $logger->getBuffer() ); - $logger->clearBuffer(); - - // Test prevention by a previous session - $session->set( 'MWSession::AutoCreateBlacklist', 'test' ); - $user = User::newFromName( 'UTDoesNotExist' ); - $this->assertFalse( $manager->autoCreateUser( $user ) ); - $this->assertSame( 0, $user->getId() ); - $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); - $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); - $session->clear(); - $this->assertSame( [ - [ LogLevel::DEBUG, 'blacklisted in session (test)' ], - ], $logger->getBuffer() ); - $logger->clearBuffer(); - - // Test uncreatable name - $user = User::newFromName( 'UTDoesNotExist@' ); - $this->assertFalse( $manager->autoCreateUser( $user ) ); - $this->assertSame( 0, $user->getId() ); - $this->assertNotSame( 'UTDoesNotExist@', $user->getName() ); - $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); - $session->clear(); - $this->assertSame( [ - [ LogLevel::DEBUG, 'Invalid username, blacklisting' ], - ], $logger->getBuffer() ); - $logger->clearBuffer(); - - // Test AbortAutoAccount hook - $mock = $this->getMock( __CLASS__, [ 'onAbortAutoAccount' ] ); - $mock->expects( $this->once() )->method( 'onAbortAutoAccount' ) - ->will( $this->returnCallback( function ( User $user, &$msg ) { - $msg = 'No way!'; - return false; - } ) ); - $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'AbortAutoAccount' => [ $mock ] ] ); - $user = User::newFromName( 'UTDoesNotExist' ); - $this->assertFalse( $manager->autoCreateUser( $user ) ); - $this->assertSame( 0, $user->getId() ); - $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); - $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); - $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'AbortAutoAccount' => [] ] ); - $session->clear(); - $this->assertSame( [ - [ LogLevel::DEBUG, 'denied by hook: No way!' ], - ], $logger->getBuffer() ); - $logger->clearBuffer(); - - // Test AbortAutoAccount hook screwing up the name - $mock = $this->getMock( 'stdClass', [ 'onAbortAutoAccount' ] ); - $mock->expects( $this->once() )->method( 'onAbortAutoAccount' ) - ->will( $this->returnCallback( function ( User $user ) { - $user->setName( 'UTDoesNotExistEither' ); - } ) ); - $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'AbortAutoAccount' => [ $mock ] ] ); - try { - $user = User::newFromName( 'UTDoesNotExist' ); - $manager->autoCreateUser( $user ); - $this->fail( 'Expected exception not thrown' ); - } catch ( \UnexpectedValueException $ex ) { - $this->assertSame( - 'AbortAutoAccount hook tried to change the user name', - $ex->getMessage() - ); - } - $this->assertSame( 0, $user->getId() ); - $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); - $this->assertNotSame( 'UTDoesNotExistEither', $user->getName() ); - $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); - $this->assertEquals( 0, User::idFromName( 'UTDoesNotExistEither', User::READ_LATEST ) ); - $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'AbortAutoAccount' => [] ] ); - $session->clear(); - $this->assertSame( [], $logger->getBuffer() ); - $logger->clearBuffer(); - - // Test for "exception backoff" - $user = User::newFromName( 'UTDoesNotExist' ); - $cache = \ObjectCache::getLocalClusterInstance(); - $backoffKey = wfMemcKey( 'MWSession', 'autocreate-failed', md5( $user->getName() ) ); - $cache->set( $backoffKey, 1, 60 * 10 ); - $this->assertFalse( $manager->autoCreateUser( $user ) ); - $this->assertSame( 0, $user->getId() ); - $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); - $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); - $cache->delete( $backoffKey ); - $session->clear(); - $this->assertSame( [ - [ LogLevel::DEBUG, 'denied by prior creation attempt failures' ], - ], $logger->getBuffer() ); - $logger->clearBuffer(); - - // Sanity check that creation still works, and test completion hook - $cb = $this->callback( function ( User $user ) { - $this->assertNotEquals( 0, $user->getId() ); - $this->assertSame( 'UTSessionAutoCreate4', $user->getName() ); - $this->assertEquals( - $user->getId(), User::idFromName( 'UTSessionAutoCreate4', User::READ_LATEST ) - ); - return true; - } ); - $mock = $this->getMock( 'stdClass', - [ 'onAuthPluginAutoCreate', 'onLocalUserCreated' ] ); - $mock->expects( $this->once() )->method( 'onAuthPluginAutoCreate' ) - ->with( $cb ); - $mock->expects( $this->once() )->method( 'onLocalUserCreated' ) - ->with( $cb, $this->identicalTo( true ) ); - $this->mergeMwGlobalArrayValue( 'wgHooks', [ - 'AuthPluginAutoCreate' => [ $mock ], - 'LocalUserCreated' => [ $mock ], - ] ); - $user = User::newFromName( 'UTSessionAutoCreate4' ); - $this->assertSame( 0, $user->getId(), 'sanity check' ); - $this->assertTrue( $manager->autoCreateUser( $user ) ); - $this->assertNotEquals( 0, $user->getId() ); - $this->assertSame( 'UTSessionAutoCreate4', $user->getName() ); - $this->assertEquals( - $user->getId(), - User::idFromName( 'UTSessionAutoCreate4', User::READ_LATEST ) - ); - $this->mergeMwGlobalArrayValue( 'wgHooks', [ - 'AuthPluginAutoCreate' => [], - 'LocalUserCreated' => [], - ] ); - $this->assertSame( [ - [ LogLevel::INFO, 'creating new user ({username}) - from: {url}' ], - ], $logger->getBuffer() ); - $logger->clearBuffer(); - } - - public function onAbortAutoAccount( User $user, &$msg ) { - } - public function testPreventSessionsForUser() { $manager = $this->getManager(); @@ -1756,5 +1492,21 @@ class SessionManagerTest extends MediaWikiTestCase { [ LogLevel::WARNING, 'Session "{session}": Hook aborted' ], ], $logger->getBuffer() ); $logger->clearBuffer(); + $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionCheckInfo' => [] ] ); + + // forceUse deletes bad backend data + $this->store->setSessionMeta( $id, [ 'userToken' => 'Bad' ] + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [ + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo, + 'forceUse' => true, + ] ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertFalse( $this->store->getSession( $id ) ); + $this->assertSame( [ + [ LogLevel::WARNING, 'Session "{session}": User token mismatch' ], + ], $logger->getBuffer() ); + $logger->clearBuffer(); } } diff --git a/tests/phpunit/includes/session/SessionProviderTest.php b/tests/phpunit/includes/session/SessionProviderTest.php index 18b1efd460..8284d05e97 100644 --- a/tests/phpunit/includes/session/SessionProviderTest.php +++ b/tests/phpunit/includes/session/SessionProviderTest.php @@ -27,12 +27,16 @@ class SessionProviderTest extends MediaWikiTestCase { $this->assertSame( $manager, $priv->manager ); $this->assertSame( $manager, $provider->getManager() ); + $provider->invalidateSessionsForUser( new \User ); + $this->assertSame( [], $provider->getVaryHeaders() ); $this->assertSame( [], $provider->getVaryCookies() ); $this->assertSame( null, $provider->suggestLoginUsername( new \FauxRequest ) ); $this->assertSame( get_class( $provider ), (string)$provider ); + $this->assertNull( $provider->getRememberUserDuration() ); + $this->assertNull( $provider->whyNoSession() ); $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [ @@ -134,7 +138,6 @@ class SessionProviderTest extends MediaWikiTestCase { $ex->getMessage() ); } - } public function testHashToSessionId() { diff --git a/tests/phpunit/includes/session/SessionTest.php b/tests/phpunit/includes/session/SessionTest.php index 3815416c31..e6a6ad351f 100644 --- a/tests/phpunit/includes/session/SessionTest.php +++ b/tests/phpunit/includes/session/SessionTest.php @@ -299,7 +299,6 @@ class SessionTest extends MediaWikiTestCase { $session->resetAllTokens(); $this->assertArrayNotHasKey( 'wsTokenSecrets', $backend->data ); - } /** diff --git a/tests/phpunit/includes/session/TestUtils.php b/tests/phpunit/includes/session/TestUtils.php index f1dc9e994b..f00de55a30 100644 --- a/tests/phpunit/includes/session/TestUtils.php +++ b/tests/phpunit/includes/session/TestUtils.php @@ -12,7 +12,7 @@ class TestUtils { /** * Override the singleton for unit testing * @param SessionManager|null $manager - * @return \\ScopedCallback|null + * @return \\Wikimedia\ScopedCallback|null */ public static function setSessionManagerSingleton( SessionManager $manager = null ) { session_write_close(); @@ -45,7 +45,7 @@ class TestUtils { PHPSessionHandler::install( $manager ); } - return new \ScopedCallback( function () use ( &$reset, $oldInstance ) { + return new \Wikimedia\ScopedCallback( function () use ( &$reset, $oldInstance ) { foreach ( $reset as &$arr ) { $arr[0]->setValue( $arr[1] ); } diff --git a/tests/phpunit/includes/site/MediaWikiPageNameNormalizerTest.php b/tests/phpunit/includes/site/MediaWikiPageNameNormalizerTest.php index 3f67b2b315..64cdbaa915 100644 --- a/tests/phpunit/includes/site/MediaWikiPageNameNormalizerTest.php +++ b/tests/phpunit/includes/site/MediaWikiPageNameNormalizerTest.php @@ -29,36 +29,15 @@ use MediaWiki\Site\MediaWikiPageNameNormalizer; */ class MediaWikiPageNameNormalizerTest extends PHPUnit_Framework_TestCase { - protected function setUp() { - parent::setUp(); - - static $connectivity = null; - - if ( $connectivity === null ) { - // Check whether we have (reasonable fast) connectivity - $res = Http::get( - 'https://www.wikidata.org/w/api.php?action=query&meta=siteinfo&format=json', - [ 'timeout' => 3 ], - __METHOD__ - ); - - if ( $res === false || strpos( $res, '"sitename":"Wikidata"' ) === false ) { - $connectivity = false; - } else { - $connectivity = true; - } - } - - if ( !$connectivity ) { - $this->markTestSkipped( 'MediaWikiPageNameNormalizerTest needs internet connectivity.' ); - } - } - /** * @dataProvider normalizePageTitleProvider */ - public function testNormalizePageTitle( $expected, $pageName ) { - $normalizer = new MediaWikiPageNameNormalizer(); + public function testNormalizePageTitle( $expected, $pageName, $getResponse ) { + MediaWikiPageNameNormalizerTestMockHttp::$response = $getResponse; + + $normalizer = new MediaWikiPageNameNormalizer( + new MediaWikiPageNameNormalizerTestMockHttp() + ); $this->assertSame( $expected, @@ -67,19 +46,70 @@ class MediaWikiPageNameNormalizerTest extends PHPUnit_Framework_TestCase { } public function normalizePageTitleProvider() { - // Note: This makes (very conservative) assumptions about pages on Wikidata - // existing or not. + // Response are taken from wikidata and kkwiki using the following API request + // api.php?action=query&prop=info&redirects=1&converttitles=1&format=json&titles=… return [ 'universe (Q1)' => [ - 'Q1', 'Q1' + 'Q1', + 'Q1', + '{"batchcomplete":"","query":{"pages":{"129":{"pageid":129,"ns":0,' + . '"title":"Q1","contentmodel":"wikibase-item","pagelanguage":"en",' + . '"pagelanguagehtmlcode":"en","pagelanguagedir":"ltr",' + . '"touched":"2016-06-23T05:11:21Z","lastrevid":350004448,"length":58001}}}}' ], 'Q404 redirects to Q395' => [ - 'Q395', 'Q404' + 'Q395', + 'Q404', + '{"batchcomplete":"","query":{"redirects":[{"from":"Q404","to":"Q395"}],"pages"' + . ':{"601":{"pageid":601,"ns":0,"title":"Q395","contentmodel":"wikibase-item",' + . '"pagelanguage":"en","pagelanguagehtmlcode":"en","pagelanguagedir":"ltr",' + . '"touched":"2016-06-23T08:00:20Z","lastrevid":350021914,"length":60108}}}}' + ], + 'D converted to Д (Latin to Cyrillic) (taken from kkwiki)' => [ + 'Д', + 'D', + '{"batchcomplete":"","query":{"converted":[{"from":"D","to":"\u0414"}],' + . '"pages":{"510541":{"pageid":510541,"ns":0,"title":"\u0414",' + . '"contentmodel":"wikitext","pagelanguage":"kk","pagelanguagehtmlcode":"kk",' + . '"pagelanguagedir":"ltr","touched":"2015-11-22T09:16:18Z",' + . '"lastrevid":2373618,"length":3501}}}}' ], 'there is no Q0' => [ - false, 'Q0' - ] + false, + 'Q0', + '{"batchcomplete":"","query":{"pages":{"-1":{"ns":0,"title":"Q0",' + . '"missing":"","contentmodel":"wikibase-item","pagelanguage":"en",' + . '"pagelanguagehtmlcode":"en","pagelanguagedir":"ltr"}}}}' + ], + 'invalid title' => [ + false, + '{{', + '{"batchcomplete":"","query":{"pages":{"-1":{"title":"{{",' + . '"invalidreason":"The requested page title contains invalid ' + . 'characters: \"{\".","invalid":""}}}}' + ], + 'error on get' => [ false, 'ABC', false ] ]; } } + +/** + * @private + * @see Http + */ +class MediaWikiPageNameNormalizerTestMockHttp extends Http { + + /** + * @var mixed + */ + public static $response; + + public static function get( $url, $options = [], $caller = __METHOD__ ) { + PHPUnit_Framework_Assert::assertInternalType( 'string', $url ); + PHPUnit_Framework_Assert::assertInternalType( 'array', $options ); + PHPUnit_Framework_Assert::assertInternalType( 'string', $caller ); + + return self::$response; + } +} diff --git a/tests/phpunit/includes/skins/SkinTemplateTest.php b/tests/phpunit/includes/skins/SkinTemplateTest.php index 9e3a620773..ff544cd23d 100644 --- a/tests/phpunit/includes/skins/SkinTemplateTest.php +++ b/tests/phpunit/includes/skins/SkinTemplateTest.php @@ -35,7 +35,7 @@ class SkinTemplateTest extends MediaWikiTestCase { 'text' => 'text' ], [], - 'Test makteListItem with normal values' + 'Test makeListItem with normal values' ] ]; } diff --git a/tests/phpunit/includes/specialpage/SpecialPageFactoryTest.php b/tests/phpunit/includes/specialpage/SpecialPageFactoryTest.php index 3d407fbc32..f79f6e48c5 100644 --- a/tests/phpunit/includes/specialpage/SpecialPageFactoryTest.php +++ b/tests/phpunit/includes/specialpage/SpecialPageFactoryTest.php @@ -1,4 +1,6 @@ [ 'SpecialAllPages' ], + 'class name' => [ 'SpecialAllPages', false ], 'closure' => [ function () { return new SpecialAllPages(); - } ], - 'function' => [ [ $this, 'newSpecialAllPages' ] ], - 'callback string' => [ 'SpecialPageTestHelper::newSpecialAllPages' ], + }, false ], + 'function' => [ [ $this, 'newSpecialAllPages' ], false ], + 'callback string' => [ 'SpecialPageTestHelper::newSpecialAllPages', false ], 'callback with object' => [ - [ $specialPageTestHelper, 'newSpecialAllPages' ] + [ $specialPageTestHelper, 'newSpecialAllPages' ], + false ], 'callback array' => [ - [ 'SpecialPageTestHelper', 'newSpecialAllPages' ] + [ 'SpecialPageTestHelper', 'newSpecialAllPages' ], + false ] ]; } @@ -74,7 +78,7 @@ class SpecialPageFactoryTest extends MediaWikiTestCase { * @covers SpecialPageFactory::getPage * @dataProvider specialPageProvider */ - public function testGetPage( $spec ) { + public function testGetPage( $spec, $shouldReuseInstance ) { $this->mergeMwGlobalArrayValue( 'wgSpecialPages', [ 'testdummy' => $spec ] ); SpecialPageFactory::resetList(); @@ -82,7 +86,7 @@ class SpecialPageFactoryTest extends MediaWikiTestCase { $this->assertInstanceOf( 'SpecialPage', $page ); $page2 = SpecialPageFactory::getPage( 'testdummy' ); - $this->assertEquals( true, $page2 === $page, "Should re-use instance:" ); + $this->assertEquals( $shouldReuseInstance, $page2 === $page, "Should re-use instance:" ); } /** diff --git a/tests/phpunit/includes/specials/QueryAllSpecialPagesTest.php b/tests/phpunit/includes/specials/QueryAllSpecialPagesTest.php index 061e59823a..c1083afdbc 100644 --- a/tests/phpunit/includes/specials/QueryAllSpecialPagesTest.php +++ b/tests/phpunit/includes/specials/QueryAllSpecialPagesTest.php @@ -22,7 +22,7 @@ class QueryAllSpecialPagesTest extends MediaWikiTestCase { * Pages whose query use the same DB table more than once. * This is used to skip testing those pages when run against a MySQL backend * which does not support reopening a temporary table. See upstream bug: - * http://bugs.mysql.com/bug.php?id=10327 + * https://bugs.mysql.com/bug.php?id=10327 */ protected $reopensTempTable = [ 'BrokenRedirects', @@ -51,7 +51,7 @@ class QueryAllSpecialPagesTest extends MediaWikiTestCase { foreach ( $this->queryPages as $page ) { // With MySQL, skips special pages reopening a temporary table - // See http://bugs.mysql.com/bug.php?id=10327 + // See https://bugs.mysql.com/bug.php?id=10327 if ( $wgDBtype === 'mysql' && in_array( $page->getName(), $this->reopensTempTable ) diff --git a/tests/phpunit/includes/specials/SpecialBooksourcesTest.php b/tests/phpunit/includes/specials/SpecialBooksourcesTest.php index 3310d0217a..074045d79f 100644 --- a/tests/phpunit/includes/specials/SpecialBooksourcesTest.php +++ b/tests/phpunit/includes/specials/SpecialBooksourcesTest.php @@ -1,5 +1,5 @@ assertSame( $isValid, SpecialBookSources::isValidISBN( $isbn ) ); } + + protected function newSpecialPage() { + return new SpecialBookSources(); + } + + /** + * @covers SpecialBookSources::execute + */ + public function testExecute() { + list( $html, ) = $this->executeSpecialPage( 'Invalid', null, 'qqx' ); + $this->assertContains( '(booksources-invalid-isbn)', $html ); + list( $html, ) = $this->executeSpecialPage( '0-7475-3269-9', null, 'qqx' ); + $this->assertNotContains( '(booksources-invalid-isbn)', $html ); + $this->assertContains( '(booksources-text)', $html ); + } } diff --git a/tests/phpunit/includes/specials/SpecialRecentchangesTest.php b/tests/phpunit/includes/specials/SpecialRecentchangesTest.php index cc16e5f7bd..c51217c5b9 100644 --- a/tests/phpunit/includes/specials/SpecialRecentchangesTest.php +++ b/tests/phpunit/includes/specials/SpecialRecentchangesTest.php @@ -22,9 +22,17 @@ class SpecialRecentchangesTest extends MediaWikiTestCase { protected $rc; /** helper to test SpecialRecentchanges::buildMainQueryConds() */ - private function assertConditions( $expected, $requestOptions = null, $message = '' ) { + private function assertConditions( + $expected, + $requestOptions = null, + $message = '', + $user = null + ) { $context = new RequestContext; $context->setRequest( new FauxRequest( $requestOptions ) ); + if ( $user ) { + $context->setUser( $user ); + } # setup the rc object $this->rc = new SpecialRecentChanges(); @@ -129,4 +137,82 @@ class SpecialRecentchangesTest extends MediaWikiTestCase { [ NS_TALK, NS_MAIN ], ]; } + + public function testRcHidemyselfFilter() { + $user = $this->getTestUser()->getUser(); + $this->assertConditions( + [ # expected + 'rc_bot' => 0, + 0 => "rc_user != '{$user->getId()}'", + 1 => "rc_type != '6'", + ], + [ + 'hidemyself' => 1, + ], + "rc conditions: hidemyself=1 (logged in)", + $user + ); + + $user = User::newFromName( '10.11.12.13', false ); + $this->assertConditions( + [ # expected + 'rc_bot' => 0, + 0 => "rc_user_text != '10.11.12.13'", + 1 => "rc_type != '6'", + ], + [ + 'hidemyself' => 1, + ], + "rc conditions: hidemyself=1 (anon)", + $user + ); + } + + public function testRcHidebyothersFilter() { + $user = $this->getTestUser()->getUser(); + $this->assertConditions( + [ # expected + 'rc_bot' => 0, + 0 => "rc_user = '{$user->getId()}'", + 1 => "rc_type != '6'", + ], + [ + 'hidebyothers' => 1, + ], + "rc conditions: hidebyothers=1 (logged in)", + $user + ); + + $user = User::newFromName( '10.11.12.13', false ); + $this->assertConditions( + [ # expected + 'rc_bot' => 0, + 0 => "rc_user_text = '10.11.12.13'", + 1 => "rc_type != '6'", + ], + [ + 'hidebyothers' => 1, + ], + "rc conditions: hidebyothers=1 (anon)", + $user + ); + } + + public function testRcHidemyselfHidebyothersFilter() { + $user = $this->getTestUser()->getUser(); + $this->assertConditions( + [ # expected + 'rc_bot' => 0, + 0 => "rc_user != '{$user->getId()}'", + 1 => "rc_user = '{$user->getId()}'", + 2 => "rc_type != '6'", + ], + [ + 'hidemyself' => 1, + 'hidebyothers' => 1, + ], + "rc conditions: hidemyself=1 hidebyothers=1 (logged in)", + $user + ); + } } diff --git a/tests/phpunit/includes/specials/SpecialSearchTest.php b/tests/phpunit/includes/specials/SpecialSearchTest.php index 4ea9686680..3fa8a9f8ed 100644 --- a/tests/phpunit/includes/specials/SpecialSearchTest.php +++ b/tests/phpunit/includes/specials/SpecialSearchTest.php @@ -29,10 +29,10 @@ class SpecialSearchTest extends MediaWikiTestCase { $this->newUserWithSearchNS( $userOptions ) ); /* - $context->setRequest( new FauxRequest( array( + $context->setRequest( new FauxRequest( [ 'ns5'=>true, 'ns6'=>true, - ) )); + ] )); */ $context->setRequest( new FauxRequest( $requested ) ); $search = new SpecialSearch(); diff --git a/tests/phpunit/includes/tidy/BalancerTest.php b/tests/phpunit/includes/tidy/BalancerTest.php new file mode 100644 index 0000000000..f69ecafd5c --- /dev/null +++ b/tests/phpunit/includes/tidy/BalancerTest.php @@ -0,0 +1,155 @@ +balancer = new MediaWiki\Tidy\Balancer( [ + 'strict' => false, /* not strict */ + 'allowedHtmlElements' => null, /* no sanitization */ + 'tidyCompat' => false, /* standard parser */ + 'allowComments' => true, /* comment parsing */ + ] ); + } + + /** + * @covers MediaWiki\Tidy\Balancer::balance + * @dataProvider provideBalancerTests + */ + public function testBalancer( $description, $input, $expected ) { + $output = $this->balancer->balance( $input ); + + // Ignore self-closing tags + $output = preg_replace( '/\s*\/>/', '>', $output ); + + $this->assertEquals( $expected, $output, $description ); + } + + public static function provideBalancerTests() { + // Get the tests from html5lib-tests.json + $json = json_decode( file_get_contents( + __DIR__ . '/html5lib-tests.json' + ), true ); + // Munge this slightly into the format phpunit expects + // for providers, and filter out HTML constructs which + // the balancer doesn't support. + $tests = []; + $okre = "~ \A + (?i:)? + + .* + + \z ~xs"; + foreach ( $json as $filename => $cases ) { + foreach ( $cases as $case ) { + $html = $case['document']['html']; + if ( !preg_match( $okre, $html ) ) { + // Skip tests which involve stuff in the or + // weird doctypes. + continue; + } + // We used to do this: + // $html = substr( $html, strlen( $start ), -strlen( $end ) ); + // But now we use a different field in the test case, + // which reports how domino would parse this case in a + // no-quirks context. (The original test case may + // have had a different context, or relied on quirks mode.) + $html = $case['document']['noQuirksBodyHtml']; + // Normalize case of SVG attributes. + $html = str_replace( 'foreignObject', 'foreignobject', $html ); + // Normalize case of MathML attributes. + $html = str_replace( 'definitionURL', 'definitionurl', $html ); + + if ( + isset( $case['document']['props']['comment'] ) && + preg_match( ',BAZ", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "comment": " BAR " + }, + { + "text": "BAZ" + } + ] + } + ] + } + ], + "html": "FOOBAZ", + "noQuirksBodyHtml": "FOOBAZ" + } + }, + { + "data": "FOOBAZ", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,15): unexpected-bang-after-double-dash-in-comment" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "comment": " BAR " + }, + { + "text": "BAZ" + } + ] + } + ] + } + ], + "html": "FOOBAZ", + "noQuirksBodyHtml": "FOOBAZ" + } + }, + { + "data": "FOO", + "noQuirksBodyHtml": "FOO" + } + }, + { + "data": "FOOBAZ", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,15): unexpected-char-in-comment", + "(1,24): unexpected-char-in-comment" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "comment": " BAR -- -- MUX " + }, + { + "text": "BAZ" + } + ] + } + ] + } + ], + "html": "FOOBAZ", + "noQuirksBodyHtml": "FOOBAZ" + } + }, + { + "data": "FOOBAZ", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,15): unexpected-char-in-comment", + "(1,24): unexpected-char-in-comment", + "(1,31): unexpected-bang-after-double-dash-in-comment" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "comment": " BAR -- -- MUX " + }, + { + "text": "BAZ" + } + ] + } + ] + } + ], + "html": "FOOBAZ", + "noQuirksBodyHtml": "FOOBAZ" + } + }, + { + "data": "FOO", + "noQuirksBodyHtml": "FOO" + } + }, + { + "data": "FOOBAZ", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "comment": "" + }, + { + "text": "BAZ" + } + ] + } + ] + } + ], + "html": "FOOBAZ", + "noQuirksBodyHtml": "FOOBAZ" + } + }, + { + "data": "FOOBAZ", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,9): incorrect-comment" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "comment": "" + }, + { + "text": "BAZ" + } + ] + } + ] + } + ], + "html": "FOOBAZ", + "noQuirksBodyHtml": "FOOBAZ" + } + }, + { + "data": "FOOBAZ", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,8): incorrect-comment" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "comment": "" + }, + { + "text": "BAZ" + } + ] + } + ] + } + ], + "html": "FOOBAZ", + "noQuirksBodyHtml": "FOOBAZ" + } + }, + { + "data": "Hi", + "errors": [ + "(1,1): expected-tag-name-but-got-question-mark", + "(1,22): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "comment": "?xml version=\"1.0\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hi" + } + ] + } + ] + } + ], + "html": "Hi", + "noQuirksBodyHtml": "Hi" + } + }, + { + "data": "", + "errors": [ + "(1,1): expected-tag-name-but-got-question-mark", + "(1,20): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "comment": "?xml version=\"1.0\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "FOOBAZ", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,10): unexpected-dash-after-double-dash-in-comment" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "comment": "-" + }, + { + "text": "BAZ" + } + ] + } + ] + } + ], + "html": "FOOBAZ", + "noQuirksBodyHtml": "FOOBAZ" + } + }, + { + "data": "Comment before head", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "comment": " comment " + }, + { + "tag": "head", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "Comment before head" + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "Comment before head", + "noQuirksBodyHtml": "Comment before head" + } + } + ], + "doctype01.dat": [ + { + "data": "Hello", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,9): need-space-after-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,9): need-space-after-doctype", + "(1,10): expected-doctype-name-but-got-right-bracket", + "(1,10): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,11): expected-doctype-name-but-got-right-bracket", + "(1,11): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,17): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,18): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,17): expected-space-or-right-bracket-in-doctype", + "(1,22): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,17): expected-space-or-right-bracket-in-doctype", + "(1,27): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,24): unexpected-char-in-doctype", + "(1,24): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,28): unexpected-char-in-doctype", + "(1,28): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,34): unexpected-char-in-doctype", + "(1,37): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,25): unexpected-char-in-doctype", + "(1,31): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,32): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato \"\" \"taco\"\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,31): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato \"\" \"taco\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,33): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato \"\" \"tai'co\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,24): unexpected-char-in-doctype", + "(1,34): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,17): expected-space-or-right-bracket-in-doctype", + "(1,35): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,24): unexpected-end-of-doctype", + "(1,24): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,25): unexpected-end-of-doctype", + "(1,25): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,24): unexpected-char-in-doctype", + "(1,28): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,25): unexpected-char-in-doctype", + "(1,29): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,32): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato \"go'of\" \"\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,29): unexpected-char-in-doctype", + "(1,32): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato \"go\" \"\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,38): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato \"go:hh of\" \"\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,38): unexpected-char-in-doctype", + "(1,48): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "potato \"W3C-//dfdf\" \"\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "Hello", + "errors": [ + "(1,14): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "..." + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ], + "html": "Hello", + "noQuirksBodyHtml": "Hello" + } + }, + { + "data": "", + "errors": [ + "(2,58): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "", + "errors": [ + "(2,54): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html \"-//W3C//DTD XHTML 1.0 Frameset//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "\n]>", + "errors": [ + "(1,23): expected-space-or-right-bracket-in-doctype", + "(2,30): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true, + "escaped": true + }, + "tree": [ + { + "doctype": "root-element" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "]>", + "escaped": true + } + ] + } + ] + } + ], + "html": "]>", + "noQuirksBodyHtml": "\n]>" + } + }, + { + "data": "", + "errors": [ + "(3,53): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html \"-//WAPFORUM//DTD XHTML Mobile 1.0//EN\" \"http://www.wapforum.org/DTD/xhtml-mobile10.dtd\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "Mine!", + "errors": [ + "(1,63): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html \"\" \"http://www.w3.org/DTD/HTML4-strict.dtd\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "Mine!" + } + ] + } + ] + } + ] + } + ], + "html": "Mine!", + "noQuirksBodyHtml": "Mine!" + } + }, + { + "data": "", + "errors": [ + "(1,50): unexpected-char-in-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "", + "errors": [ + "(1,50): unexpected-char-in-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "", + "errors": [ + "(1,21): unexpected-char-in-doctype", + "(1,49): unexpected-char-in-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "", + "errors": [ + "(1,21): unexpected-char-in-doctype", + "(1,49): unexpected-char-in-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + } + ], + "domjs-unsafe.dat": [ + { + "data": "foo\nbar", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(2,6): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo\nbar" + } + ] + } + ] + } + ] + } + ], + "html": "foo\nbar", + "noQuirksBodyHtml": "foo\nbar" + } + }, + { + "data": "foo\rbar", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(2,6): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo\nbar" + } + ] + } + ] + } + ] + } + ], + "html": "foo\nbar", + "noQuirksBodyHtml": "foo\nbar" + } + }, + { + "data": "foo\r\nbar", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(2,6): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo\nbar" + } + ] + } + ] + } + ] + } + ], + "html": "foo\nbar", + "noQuirksBodyHtml": "foo\nbar" + } + }, + { + "data": "", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,12): invalid-codepoint" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "a='�'", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "", + "errors": [ + "(1,20): expected-doctype-but-got-start-tag", + "(1,25): invalid-codepoint" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "attrs": [ + { + "name": "type", + "value": "data" + } + ], + "children": [ + { + "text": "", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "colgroup": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "colgroup", + "children": [ + { + "comment": "test" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,23): non-html-root" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "colgroup": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "colgroup" + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "foo
    ", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,32): foster-parenting-character-in-table", + "(1,32): foster-parenting-character-in-table", + "(1,32): foster-parenting-character-in-table", + "(1,32): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "colgroup": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "foo" + }, + { + "tag": "table", + "children": [ + { + "tag": "colgroup", + "children": [ + { + "text": " " + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "foo
    ", + "noQuirksBodyHtml": "foo
    " + } + }, + { + "data": "", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "comment": "test" + } + ] + } + ] + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,14): non-html-root" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + } + ] + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(1,16): non-html-root" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(1,27): non-html-root" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(1,36): unexpected-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,41): unexpected-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,20): unexpected-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg font": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "font", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg font": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "font", + "ns": "http://www.w3.org/2000/svg", + "attrs": [ + { + "name": "id", + "value": "foo" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,18): unexpected-html-element-in-foreign-content", + "(1,31): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "font": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ] + } + ] + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,21): unexpected-html-element-in-foreign-content", + "(1,34): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "font": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "font", + "attrs": [ + { + "name": "color", + "value": "red" + } + ] + } + ] + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg font": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "font", + "ns": "http://www.w3.org/2000/svg", + "attrs": [ + { + "name": "font", + "value": "sans" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + } + ], + "entities01.dat": [ + { + "data": "FOO>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO>BAR", + "escaped": true + } + ] + } + ] + } + ], + "html": "FOO>BAR", + "noQuirksBodyHtml": "FOO>BAR" + } + }, + { + "data": "FOO>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,6): named-entity-without-semicolon" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO>BAR", + "escaped": true + } + ] + } + ] + } + ], + "html": "FOO>BAR", + "noQuirksBodyHtml": "FOO>BAR" + } + }, + { + "data": "FOO> BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,6): named-entity-without-semicolon" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO> BAR", + "escaped": true + } + ] + } + ] + } + ], + "html": "FOO> BAR", + "noQuirksBodyHtml": "FOO> BAR" + } + }, + { + "data": "FOO>;;BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO>;;BAR", + "escaped": true + } + ] + } + ] + } + ], + "html": "FOO>;;BAR", + "noQuirksBodyHtml": "FOO>;;BAR" + } + }, + { + "data": "I'm ¬it; I tell you", + "errors": [ + "(1,4): expected-doctype-but-got-chars", + "(1,9): named-entity-without-semicolon" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "I'm ¬it; I tell you" + } + ] + } + ] + } + ], + "html": "I'm ¬it; I tell you", + "noQuirksBodyHtml": "I'm ¬it; I tell you" + } + }, + { + "data": "I'm ∉ I tell you", + "errors": [ + "(1,4): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "I'm ∉ I tell you" + } + ] + } + ] + } + ], + "html": "I'm ∉ I tell you", + "noQuirksBodyHtml": "I'm ∉ I tell you" + } + }, + { + "data": "FOO& BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO& BAR", + "escaped": true + } + ] + } + ] + } + ], + "html": "FOO& BAR", + "noQuirksBodyHtml": "FOO& BAR" + } + }, + { + "data": "FOO&", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,9): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "bar": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO&", + "escaped": true + }, + { + "tag": "bar" + } + ] + } + ] + } + ], + "html": "FOO&", + "noQuirksBodyHtml": "FOO&" + } + }, + { + "data": "FOO&&&>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO&&&>BAR", + "escaped": true + } + ] + } + ] + } + ], + "html": "FOO&&&>BAR", + "noQuirksBodyHtml": "FOO&&&>BAR" + } + }, + { + "data": "FOO)BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO)BAR" + } + ] + } + ] + } + ], + "html": "FOO)BAR", + "noQuirksBodyHtml": "FOO)BAR" + } + }, + { + "data": "FOOABAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOABAR" + } + ] + } + ] + } + ], + "html": "FOOABAR", + "noQuirksBodyHtml": "FOOABAR" + } + }, + { + "data": "FOOABAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOABAR" + } + ] + } + ] + } + ], + "html": "FOOABAR", + "noQuirksBodyHtml": "FOOABAR" + } + }, + { + "data": "FOO&#BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,5): expected-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO&#BAR", + "escaped": true + } + ] + } + ] + } + ], + "html": "FOO&#BAR", + "noQuirksBodyHtml": "FOO&#BAR" + } + }, + { + "data": "FOO&#ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,5): expected-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO&#ZOO", + "escaped": true + } + ] + } + ] + } + ], + "html": "FOO&#ZOO", + "noQuirksBodyHtml": "FOO&#ZOO" + } + }, + { + "data": "FOOºR", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,7): expected-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOºR" + } + ] + } + ] + } + ], + "html": "FOOºR", + "noQuirksBodyHtml": "FOOºR" + } + }, + { + "data": "FOO&#xZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,6): expected-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO&#xZOO", + "escaped": true + } + ] + } + ] + } + ], + "html": "FOO&#xZOO", + "noQuirksBodyHtml": "FOO&#xZOO" + } + }, + { + "data": "FOO&#XZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,6): expected-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO&#XZOO", + "escaped": true + } + ] + } + ] + } + ], + "html": "FOO&#XZOO", + "noQuirksBodyHtml": "FOO&#XZOO" + } + }, + { + "data": "FOO)BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,7): numeric-entity-without-semicolon" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO)BAR" + } + ] + } + ] + } + ], + "html": "FOO)BAR", + "noQuirksBodyHtml": "FOO)BAR" + } + }, + { + "data": "FOO䆺R", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,10): numeric-entity-without-semicolon" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO䆺R" + } + ] + } + ] + } + ], + "html": "FOO䆺R", + "noQuirksBodyHtml": "FOO䆺R" + } + }, + { + "data": "FOOAZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,8): numeric-entity-without-semicolon" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOAZOO" + } + ] + } + ] + } + ], + "html": "FOOAZOO", + "noQuirksBodyHtml": "FOOAZOO" + } + }, + { + "data": "FOO�ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO�ZOO" + } + ] + } + ] + } + ], + "html": "FOO�ZOO", + "noQuirksBodyHtml": "FOO�ZOO" + } + }, + { + "data": "FOOxZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOxZOO" + } + ] + } + ] + } + ], + "html": "FOOxZOO", + "noQuirksBodyHtml": "FOOxZOO" + } + }, + { + "data": "FOOyZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOyZOO" + } + ] + } + ] + } + ], + "html": "FOOyZOO", + "noQuirksBodyHtml": "FOOyZOO" + } + }, + { + "data": "FOO€ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO€ZOO" + } + ] + } + ] + } + ], + "html": "FOO€ZOO", + "noQuirksBodyHtml": "FOO€ZOO" + } + }, + { + "data": "FOOZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOZOO" + } + ] + } + ] + } + ], + "html": "FOOZOO", + "noQuirksBodyHtml": "FOOZOO" + } + }, + { + "data": "FOO‚ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO‚ZOO" + } + ] + } + ] + } + ], + "html": "FOO‚ZOO", + "noQuirksBodyHtml": "FOO‚ZOO" + } + }, + { + "data": "FOOƒZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOƒZOO" + } + ] + } + ] + } + ], + "html": "FOOƒZOO", + "noQuirksBodyHtml": "FOOƒZOO" + } + }, + { + "data": "FOO„ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO„ZOO" + } + ] + } + ] + } + ], + "html": "FOO„ZOO", + "noQuirksBodyHtml": "FOO„ZOO" + } + }, + { + "data": "FOO…ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO…ZOO" + } + ] + } + ] + } + ], + "html": "FOO…ZOO", + "noQuirksBodyHtml": "FOO…ZOO" + } + }, + { + "data": "FOO†ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO†ZOO" + } + ] + } + ] + } + ], + "html": "FOO†ZOO", + "noQuirksBodyHtml": "FOO†ZOO" + } + }, + { + "data": "FOO‡ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO‡ZOO" + } + ] + } + ] + } + ], + "html": "FOO‡ZOO", + "noQuirksBodyHtml": "FOO‡ZOO" + } + }, + { + "data": "FOOˆZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOˆZOO" + } + ] + } + ] + } + ], + "html": "FOOˆZOO", + "noQuirksBodyHtml": "FOOˆZOO" + } + }, + { + "data": "FOO‰ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO‰ZOO" + } + ] + } + ] + } + ], + "html": "FOO‰ZOO", + "noQuirksBodyHtml": "FOO‰ZOO" + } + }, + { + "data": "FOOŠZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOÅ ZOO" + } + ] + } + ] + } + ], + "html": "FOOÅ ZOO", + "noQuirksBodyHtml": "FOOÅ ZOO" + } + }, + { + "data": "FOO‹ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO‹ZOO" + } + ] + } + ] + } + ], + "html": "FOO‹ZOO", + "noQuirksBodyHtml": "FOO‹ZOO" + } + }, + { + "data": "FOOŒZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOŒZOO" + } + ] + } + ] + } + ], + "html": "FOOŒZOO", + "noQuirksBodyHtml": "FOOŒZOO" + } + }, + { + "data": "FOOZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOZOO" + } + ] + } + ] + } + ], + "html": "FOOZOO", + "noQuirksBodyHtml": "FOOZOO" + } + }, + { + "data": "FOOŽZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOŽZOO" + } + ] + } + ] + } + ], + "html": "FOOŽZOO", + "noQuirksBodyHtml": "FOOŽZOO" + } + }, + { + "data": "FOOZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOZOO" + } + ] + } + ] + } + ], + "html": "FOOZOO", + "noQuirksBodyHtml": "FOOZOO" + } + }, + { + "data": "FOOZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOZOO" + } + ] + } + ] + } + ], + "html": "FOOZOO", + "noQuirksBodyHtml": "FOOZOO" + } + }, + { + "data": "FOO‘ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO‘ZOO" + } + ] + } + ] + } + ], + "html": "FOO‘ZOO", + "noQuirksBodyHtml": "FOO‘ZOO" + } + }, + { + "data": "FOO’ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO’ZOO" + } + ] + } + ] + } + ], + "html": "FOO’ZOO", + "noQuirksBodyHtml": "FOO’ZOO" + } + }, + { + "data": "FOO“ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO“ZOO" + } + ] + } + ] + } + ], + "html": "FOO“ZOO", + "noQuirksBodyHtml": "FOO“ZOO" + } + }, + { + "data": "FOO”ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO”ZOO" + } + ] + } + ] + } + ], + "html": "FOO”ZOO", + "noQuirksBodyHtml": "FOO”ZOO" + } + }, + { + "data": "FOO•ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO•ZOO" + } + ] + } + ] + } + ], + "html": "FOO•ZOO", + "noQuirksBodyHtml": "FOO•ZOO" + } + }, + { + "data": "FOO–ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO–ZOO" + } + ] + } + ] + } + ], + "html": "FOO–ZOO", + "noQuirksBodyHtml": "FOO–ZOO" + } + }, + { + "data": "FOO—ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO—ZOO" + } + ] + } + ] + } + ], + "html": "FOO—ZOO", + "noQuirksBodyHtml": "FOO—ZOO" + } + }, + { + "data": "FOO˜ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO˜ZOO" + } + ] + } + ] + } + ], + "html": "FOO˜ZOO", + "noQuirksBodyHtml": "FOO˜ZOO" + } + }, + { + "data": "FOO™ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO™ZOO" + } + ] + } + ] + } + ], + "html": "FOO™ZOO", + "noQuirksBodyHtml": "FOO™ZOO" + } + }, + { + "data": "FOOšZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOÅ¡ZOO" + } + ] + } + ] + } + ], + "html": "FOOÅ¡ZOO", + "noQuirksBodyHtml": "FOOÅ¡ZOO" + } + }, + { + "data": "FOO›ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO›ZOO" + } + ] + } + ] + } + ], + "html": "FOO›ZOO", + "noQuirksBodyHtml": "FOO›ZOO" + } + }, + { + "data": "FOOœZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOœZOO" + } + ] + } + ] + } + ], + "html": "FOOœZOO", + "noQuirksBodyHtml": "FOOœZOO" + } + }, + { + "data": "FOOZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOZOO" + } + ] + } + ] + } + ], + "html": "FOOZOO", + "noQuirksBodyHtml": "FOOZOO" + } + }, + { + "data": "FOOžZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOžZOO" + } + ] + } + ] + } + ], + "html": "FOOžZOO", + "noQuirksBodyHtml": "FOOžZOO" + } + }, + { + "data": "FOOŸZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOŸZOO" + } + ] + } + ] + } + ], + "html": "FOOŸZOO", + "noQuirksBodyHtml": "FOOŸZOO" + } + }, + { + "data": "FOO ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO ZOO", + "escaped": true + } + ] + } + ] + } + ], + "html": "FOO ZOO", + "noQuirksBodyHtml": "FOO ZOO" + } + }, + { + "data": "FOO퟿ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO퟿ZOO" + } + ] + } + ] + } + ], + "html": "FOO퟿ZOO", + "noQuirksBodyHtml": "FOO퟿ZOO" + } + }, + { + "data": "FOO�ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO�ZOO" + } + ] + } + ] + } + ], + "html": "FOO�ZOO", + "noQuirksBodyHtml": "FOO�ZOO" + } + }, + { + "data": "FOO�ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO�ZOO" + } + ] + } + ] + } + ], + "html": "FOO�ZOO", + "noQuirksBodyHtml": "FOO�ZOO" + } + }, + { + "data": "FOO�ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO�ZOO" + } + ] + } + ] + } + ], + "html": "FOO�ZOO", + "noQuirksBodyHtml": "FOO�ZOO" + } + }, + { + "data": "FOO�ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO�ZOO" + } + ] + } + ] + } + ], + "html": "FOO�ZOO", + "noQuirksBodyHtml": "FOO�ZOO" + } + }, + { + "data": "FOOZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOZOO" + } + ] + } + ] + } + ], + "html": "FOOZOO", + "noQuirksBodyHtml": "FOOZOO" + } + }, + { + "data": "FOO􏿾ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,13): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOô¿¾ZOO" + } + ] + } + ] + } + ], + "html": "FOOô¿¾ZOO", + "noQuirksBodyHtml": "FOOô¿¾ZOO" + } + }, + { + "data": "FOO􈟔ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOôˆŸ”ZOO" + } + ] + } + ] + } + ], + "html": "FOOôˆŸ”ZOO", + "noQuirksBodyHtml": "FOOôˆŸ”ZOO" + } + }, + { + "data": "FOO􏿿ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,13): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOOô¿¿ZOO" + } + ] + } + ] + } + ], + "html": "FOOô¿¿ZOO", + "noQuirksBodyHtml": "FOOô¿¿ZOO" + } + }, + { + "data": "FOO�ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,13): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO�ZOO" + } + ] + } + ] + } + ], + "html": "FOO�ZOO", + "noQuirksBodyHtml": "FOO�ZOO" + } + }, + { + "data": "FOO�ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,13): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO�ZOO" + } + ] + } + ] + } + ], + "html": "FOO�ZOO", + "noQuirksBodyHtml": "FOO�ZOO" + } + }, + { + "data": "FOO�", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,13): illegal-codepoint-for-numeric-entity", + "(1,13): eof-in-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO�" + } + ] + } + ] + } + ], + "html": "FOO�", + "noQuirksBodyHtml": "FOO�" + } + }, + { + "data": "FOO�", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,13): illegal-codepoint-for-numeric-entity", + "(1,13): eof-in-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO�" + } + ] + } + ] + } + ], + "html": "FOO�", + "noQuirksBodyHtml": "FOO�" + } + }, + { + "data": "FOO�", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,13): illegal-codepoint-for-numeric-entity", + "(1,13): eof-in-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO�" + } + ] + } + ] + } + ], + "html": "FOO�", + "noQuirksBodyHtml": "FOO�" + } + }, + { + "data": "FOO�ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,13): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO�ZOO" + } + ] + } + ] + } + ], + "html": "FOO�ZOO", + "noQuirksBodyHtml": "FOO�ZOO" + } + }, + { + "data": "FOO�ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,13): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO�ZOO" + } + ] + } + ] + } + ], + "html": "FOO�ZOO", + "noQuirksBodyHtml": "FOO�ZOO" + } + }, + { + "data": "FOO�ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,13): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO�ZOO" + } + ] + } + ] + } + ], + "html": "FOO�ZOO", + "noQuirksBodyHtml": "FOO�ZOO" + } + } + ], + "entities02.dat": [ + { + "data": "
    ", + "errors": [ + "(1,20): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ>YY" + } + ] + } + ] + } + ] + } + ], + "html": "
    YY\">
    ", + "noQuirksBodyHtml": "
    YY\">
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,15): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ&", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,15): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ&", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,13): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ&", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,15): named-entity-without-semicolon", + "(1,20): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ>=YY", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,20): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ>0YY", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,20): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ>9YY", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,20): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ>aYY", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,20): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ>ZYY", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,15): named-entity-without-semicolon", + "(1,20): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ> YY" + } + ] + } + ] + } + ] + } + ], + "html": "
    YY\">
    ", + "noQuirksBodyHtml": "
    YY\">
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,15): named-entity-without-semicolon", + "(1,17): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ>" + } + ] + } + ] + } + ] + } + ], + "html": "
    \">
    ", + "noQuirksBodyHtml": "
    \">
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,15): named-entity-without-semicolon", + "(1,17): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ>" + } + ] + } + ] + } + ] + } + ], + "html": "
    \">
    ", + "noQuirksBodyHtml": "
    \">
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,14): named-entity-without-semicolon", + "(1,15): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ>" + } + ] + } + ] + } + ] + } + ], + "html": "
    \">
    ", + "noQuirksBodyHtml": "
    \">
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,18): named-entity-without-semicolon", + "(1,26): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ£_id=23" + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,25): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ&prod_id=23", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,27): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ£_id=23" + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,26): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ∏_id=23" + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,18): named-entity-without-semicolon", + "(1,23): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ£=23", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,22): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "bar", + "value": "ZZ&prod=23", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ZZ£_id=23
    ", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,13): named-entity-without-semicolon" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "ZZ£_id=23" + } + ] + } + ] + } + ] + } + ], + "html": "
    ZZ£_id=23
    ", + "noQuirksBodyHtml": "
    ZZ£_id=23
    " + } + }, + { + "data": "
    ZZ&prod_id=23
    ", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "ZZ&prod_id=23", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "
    ZZ&prod_id=23
    ", + "noQuirksBodyHtml": "
    ZZ&prod_id=23
    " + } + }, + { + "data": "
    ZZ£_id=23
    ", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "ZZ£_id=23" + } + ] + } + ] + } + ] + } + ], + "html": "
    ZZ£_id=23
    ", + "noQuirksBodyHtml": "
    ZZ£_id=23
    " + } + }, + { + "data": "
    ZZ∏_id=23
    ", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "ZZ∏_id=23" + } + ] + } + ] + } + ] + } + ], + "html": "
    ZZ∏_id=23
    ", + "noQuirksBodyHtml": "
    ZZ∏_id=23
    " + } + }, + { + "data": "
    ZZ£=23
    ", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,13): named-entity-without-semicolon" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "ZZ£=23" + } + ] + } + ] + } + ] + } + ], + "html": "
    ZZ£=23
    ", + "noQuirksBodyHtml": "
    ZZ£=23
    " + } + }, + { + "data": "
    ZZ&prod=23
    ", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "ZZ&prod=23", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "
    ZZ&prod=23
    ", + "noQuirksBodyHtml": "
    ZZ&prod=23
    " + } + }, + { + "data": "
    ZZÆ=
    ", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "ZZÆ=" + } + ] + } + ] + } + ] + } + ], + "html": "
    ZZÆ=
    ", + "noQuirksBodyHtml": "
    ZZÆ=
    " + } + } + ], + "foreign-fragment.dat": [ + { + "data": "X", + "errors": [ + "6: HTML start tag “nobr” in a foreign namespace context.", + "7: End of file seen and there were open elements.", + "6: Unclosed element “nobr”." + ], + "fragment": { + "name": "path", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": { + "svg nobr": true + } + }, + "tree": [ + { + "tag": "nobr", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "X" + } + ] + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "X", + "errors": [ + "12: HTML start tag “font” in a foreign namespace context." + ], + "fragment": { + "name": "path", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": { + "svg font": true + } + }, + "tree": [ + { + "tag": "font", + "ns": "http://www.w3.org/2000/svg", + "attrs": [ + { + "name": "color", + "value": "" + } + ] + }, + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "X", + "errors": [], + "fragment": { + "name": "path", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": { + "svg font": true + } + }, + "tree": [ + { + "tag": "font", + "ns": "http://www.w3.org/2000/svg" + }, + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "
    X", + "errors": [ + "10: End tag “path” did not match the name of the current open element (“g”).", + "11: End of file seen and there were open elements.", + "3: Unclosed element “g”." + ], + "fragment": { + "name": "path", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": { + "svg g": true + } + }, + "tree": [ + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "X" + } + ] + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "X", + "errors": [ + "5: Stray end tag “path”." + ], + "fragment": { + "name": "path", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "X", + "errors": [ + "5: Stray end tag “foreignobject”." + ], + "fragment": { + "name": "foreignObject", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "X", + "errors": [ + "5: Stray end tag “desc”." + ], + "fragment": { + "name": "desc", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "X", + "errors": [ + "5: Stray end tag “title”." + ], + "fragment": { + "name": "title", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "X", + "errors": [ + "5: Stray end tag “svg”." + ], + "fragment": { + "name": "svg", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "X", + "errors": [ + "5: Stray end tag “mfenced”." + ], + "fragment": { + "name": "mfenced", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "X", + "errors": [ + "5: Stray end tag “malignmark”." + ], + "fragment": { + "name": "malignmark", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "X", + "errors": [ + "5: Stray end tag “math”." + ], + "fragment": { + "name": "math", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "X", + "errors": [ + "5: Stray end tag “annotation-xml”." + ], + "fragment": { + "name": "annotation-xml", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "X", + "errors": [ + "5: Stray end tag “mtext”." + ], + "fragment": { + "name": "mtext", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "X", + "errors": [ + "5: Stray end tag “mi”." + ], + "fragment": { + "name": "mi", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "X", + "errors": [ + "5: Stray end tag “mo”." + ], + "fragment": { + "name": "mo", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "X", + "errors": [ + "5: Stray end tag “mn”." + ], + "fragment": { + "name": "mn", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "X", + "errors": [ + "5: Stray end tag “ms”." + ], + "fragment": { + "name": "ms", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "X", + "errors": [ + "51: Self-closing syntax (“/>”) used on a non-void HTML element. Ignoring the slash and treating as a start tag.", + "52: End of file seen and there were open elements.", + "51: Unclosed element “ms”." + ], + "fragment": { + "name": "ms", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "b": true, + "math mglyph": true, + "i": true, + "math malignmark": true, + "u": true, + "ms": true + } + }, + "tree": [ + { + "tag": "b" + }, + { + "tag": "mglyph", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "i" + }, + { + "tag": "malignmark", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "u" + }, + { + "tag": "ms", + "children": [ + { + "text": "X" + } + ] + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "", + "errors": [], + "fragment": { + "name": "ms", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "math malignmark": true + } + }, + "tree": [ + { + "tag": "malignmark", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "ms", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "div": true + } + }, + "tree": [ + { + "tag": "div" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "ms", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "figure": true + } + }, + "tree": [ + { + "tag": "figure" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "X", + "errors": [ + "51: Self-closing syntax (“/>”) used on a non-void HTML element. Ignoring the slash and treating as a start tag.", + "52: End of file seen and there were open elements.", + "51: Unclosed element “mn”." + ], + "fragment": { + "name": "mn", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "b": true, + "math mglyph": true, + "i": true, + "math malignmark": true, + "u": true, + "mn": true + } + }, + "tree": [ + { + "tag": "b" + }, + { + "tag": "mglyph", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "i" + }, + { + "tag": "malignmark", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "u" + }, + { + "tag": "mn", + "children": [ + { + "text": "X" + } + ] + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "", + "errors": [], + "fragment": { + "name": "mn", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "math malignmark": true + } + }, + "tree": [ + { + "tag": "malignmark", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "mn", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "div": true + } + }, + "tree": [ + { + "tag": "div" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "mn", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "figure": true + } + }, + "tree": [ + { + "tag": "figure" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "X", + "errors": [ + "51: Self-closing syntax (“/>”) used on a non-void HTML element. Ignoring the slash and treating as a start tag.", + "52: End of file seen and there were open elements.", + "51: Unclosed element “mo”." + ], + "fragment": { + "name": "mo", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "b": true, + "math mglyph": true, + "i": true, + "math malignmark": true, + "u": true, + "mo": true + } + }, + "tree": [ + { + "tag": "b" + }, + { + "tag": "mglyph", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "i" + }, + { + "tag": "malignmark", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "u" + }, + { + "tag": "mo", + "children": [ + { + "text": "X" + } + ] + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "", + "errors": [], + "fragment": { + "name": "mo", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "math malignmark": true + } + }, + "tree": [ + { + "tag": "malignmark", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "mo", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "div": true + } + }, + "tree": [ + { + "tag": "div" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "mo", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "figure": true + } + }, + "tree": [ + { + "tag": "figure" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "X", + "errors": [ + "51: Self-closing syntax (“/>”) used on a non-void HTML element. Ignoring the slash and treating as a start tag.", + "52: End of file seen and there were open elements.", + "51: Unclosed element “mi”." + ], + "fragment": { + "name": "mi", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "b": true, + "math mglyph": true, + "i": true, + "math malignmark": true, + "u": true, + "mi": true + } + }, + "tree": [ + { + "tag": "b" + }, + { + "tag": "mglyph", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "i" + }, + { + "tag": "malignmark", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "u" + }, + { + "tag": "mi", + "children": [ + { + "text": "X" + } + ] + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "", + "errors": [], + "fragment": { + "name": "mi", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "math malignmark": true + } + }, + "tree": [ + { + "tag": "malignmark", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "mi", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "div": true + } + }, + "tree": [ + { + "tag": "div" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "mi", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "figure": true + } + }, + "tree": [ + { + "tag": "figure" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "X", + "errors": [ + "51: Self-closing syntax (“/>”) used on a non-void HTML element. Ignoring the slash and treating as a start tag.", + "52: End of file seen and there were open elements.", + "51: Unclosed element “mtext”." + ], + "fragment": { + "name": "mtext", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "b": true, + "math mglyph": true, + "i": true, + "math malignmark": true, + "u": true, + "mtext": true + } + }, + "tree": [ + { + "tag": "b" + }, + { + "tag": "mglyph", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "i" + }, + { + "tag": "malignmark", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "u" + }, + { + "tag": "mtext", + "children": [ + { + "text": "X" + } + ] + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "", + "errors": [], + "fragment": { + "name": "mtext", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "math malignmark": true + } + }, + "tree": [ + { + "tag": "malignmark", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ], + "html": "", + "noQuirksBodyHtml": "" + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "mtext", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "div": true + } + }, + "tree": [ + { + "tag": "div" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "mtext", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "figure": true + } + }, + "tree": [ + { + "tag": "figure" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [ + "5: HTML start tag “div” in a foreign namespace context." + ], + "fragment": { + "name": "annotation-xml", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "math div": true + } + }, + "tree": [ + { + "tag": "div", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "annotation-xml", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "math figure": true + } + }, + "tree": [ + { + "tag": "figure", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [ + "5: HTML start tag “div” in a foreign namespace context." + ], + "fragment": { + "name": "math", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "math div": true + } + }, + "tree": [ + { + "tag": "div", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "math", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + "document": { + "props": { + "tags": { + "math figure": true + } + }, + "tree": [ + { + "tag": "figure", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "foreignObject", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": { + "div": true + } + }, + "tree": [ + { + "tag": "div" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "foreignObject", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": { + "figure": true + } + }, + "tree": [ + { + "tag": "figure" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "title", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": { + "div": true + } + }, + "tree": [ + { + "tag": "div" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "title", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": { + "figure": true + } + }, + "tree": [ + { + "tag": "figure" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "desc", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": { + "figure": true + } + }, + "tree": [ + { + "tag": "figure" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "

    X

    ", + "errors": [ + "5: HTML start tag “div” in a foreign namespace context.", + "9: HTML start tag “h1” in a foreign namespace context." + ], + "fragment": { + "name": "svg", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": { + "svg div": true, + "svg h1": true + } + }, + "tree": [ + { + "tag": "div", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "h1", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ], + "html": "

    X

    ", + "noQuirksBodyHtml": "

    X

    " + } + }, + { + "data": "
    ", + "errors": [ + "5: HTML start tag “div” in a foreign namespace context." + ], + "fragment": { + "name": "svg", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": { + "svg div": true + } + }, + "tree": [ + { + "tag": "div", + "ns": "http://www.w3.org/2000/svg" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "desc", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": { + "div": true + } + }, + "tree": [ + { + "tag": "div" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [], + "fragment": { + "name": "desc", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": { + "figure": true + } + }, + "tree": [ + { + "tag": "figure" + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "<foo>", + "errors": [ + "16: End of file seen and there were open elements.", + "11: Unclosed element “plaintext”." + ], + "fragment": { + "name": "desc", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": { + "plaintext": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "plaintext", + "children": [ + { + "text": "<foo>", + "no_escape": true + } + ] + } + ], + "html": "<plaintext><foo></plaintext>", + "noQuirksBodyHtml": "<plaintext><foo></plaintext>" + } + }, + { + "data": "<frameset>X", + "errors": [ + "6: Stray start tag “frameset”." + ], + "fragment": { + "name": "desc", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "<head>X", + "errors": [ + "6: Stray start tag “head”." + ], + "fragment": { + "name": "desc", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "<body>X", + "errors": [ + "6: Stray start tag “body”." + ], + "fragment": { + "name": "desc", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "<html>X", + "errors": [ + "6: Stray start tag “html”." + ], + "fragment": { + "name": "desc", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "<html class=\"foo\">X", + "errors": [ + "6: Stray start tag “html”." + ], + "fragment": { + "name": "desc", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "<body class=\"foo\">X", + "errors": [ + "6: Stray start tag “body”." + ], + "fragment": { + "name": "desc", + "ns": "http://www.w3.org/2000/svg" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "X" + } + ], + "html": "X", + "noQuirksBodyHtml": "X" + } + } + ], + "html5test-com.dat": [ + { + "data": "<div<div>", + "errors": [ + "(1,9): expected-doctype-but-got-start-tag", + "(1,9): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div<div": true + }, + "tagWithLt": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div<div" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div<div></div<div></body></html>", + "noQuirksBodyHtml": "<div<div></div<div>" + } + }, + { + "data": "<div foo<bar=''>", + "errors": [ + "(1,9): invalid-character-in-attribute-name", + "(1,16): expected-doctype-but-got-start-tag", + "(1,16): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "attrWithFunnyChar": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "foo<bar", + "value": "" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div foo<bar=\"\"></div></body></html>", + "noQuirksBodyHtml": "<div foo<bar=\"\"></div>" + } + }, + { + "data": "<div foo=`bar`>", + "errors": [ + "(1,10): equals-in-unquoted-attribute-value", + "(1,14): unexpected-character-in-unquoted-attribute-value", + "(1,15): expected-doctype-but-got-start-tag", + "(1,15): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "foo", + "value": "`bar`" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div foo=\"`bar`\"></div></body></html>", + "noQuirksBodyHtml": "<div foo=\"`bar`\"></div>" + } + }, + { + "data": "<div \\\"foo=''>", + "errors": [ + "(1,7): invalid-character-in-attribute-name", + "(1,14): expected-doctype-but-got-start-tag", + "(1,14): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "attrWithFunnyChar": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "\\\"foo", + "value": "" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div \\\"foo=\"\"></div></body></html>", + "noQuirksBodyHtml": "<div \\\"foo=\"\"></div>" + } + }, + { + "data": "<a href='\\nbar'></a>", + "errors": [ + "(1,16): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "attrs": [ + { + "name": "href", + "value": "\\nbar" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a href=\"\\nbar\"></a></body></html>", + "noQuirksBodyHtml": "<a href=\"\\nbar\"></a>" + } + }, + { + "data": "<!DOCTYPE html>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "&lang;&rang;", + "errors": [ + "(1,6): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "⟨⟩" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>⟨⟩</body></html>", + "noQuirksBodyHtml": "⟨⟩" + } + }, + { + "data": "&apos;", + "errors": [ + "(1,6): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "'" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>'</body></html>", + "noQuirksBodyHtml": "'" + } + }, + { + "data": "&ImaginaryI;", + "errors": [ + "(1,12): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "ⅈ" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>ⅈ</body></html>", + "noQuirksBodyHtml": "ⅈ" + } + }, + { + "data": "&Kopf;", + "errors": [ + "(1,6): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "𝕂" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>𝕂</body></html>", + "noQuirksBodyHtml": "𝕂" + } + }, + { + "data": "&notinva;", + "errors": [ + "(1,9): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "∉" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>∉</body></html>", + "noQuirksBodyHtml": "∉" + } + }, + { + "data": "<?import namespace=\"foo\" implementation=\"#bar\">", + "errors": [ + "(1,1): expected-tag-name-but-got-question-mark", + "(1,47): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "comment": "?import namespace=\"foo\" implementation=\"#bar\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!--?import namespace=\"foo\" implementation=\"#bar\"--><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!--?import namespace=\"foo\" implementation=\"#bar\"-->" + } + }, + { + "data": "<!--foo--bar-->", + "errors": [ + "(1,10): unexpected-char-in-comment", + "(1,15): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "comment": "foo--bar" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!--foo--bar--><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!--foo--bar-->" + } + }, + { + "data": "<![CDATA[x]]>", + "errors": [ + "(1,2): expected-dashes-or-doctype", + "(1,13): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "comment": "[CDATA[x]]" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!--[CDATA[x]]--><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!--[CDATA[x]]-->" + } + }, + { + "data": "<textarea><!--</textarea>--></textarea>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(1,39): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "textarea": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "textarea", + "children": [ + { + "text": "<!--", + "escaped": true + } + ] + }, + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body><textarea>&lt;!--</textarea>--&gt;</body></html>", + "noQuirksBodyHtml": "<textarea>&lt;!--</textarea>--&gt;" + } + }, + { + "data": "<textarea><!--</textarea>-->", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "textarea": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "textarea", + "children": [ + { + "text": "<!--", + "escaped": true + } + ] + }, + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body><textarea>&lt;!--</textarea>--&gt;</body></html>", + "noQuirksBodyHtml": "<textarea>&lt;!--</textarea>--&gt;" + } + }, + { + "data": "<style><!--</style>--></style>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,30): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "<!--", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head><style><!--</style></head><body>--&gt;</body></html>", + "noQuirksBodyHtml": "<style><!--</style>--&gt;" + } + }, + { + "data": "<style><!--</style>-->", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "<!--", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head><style><!--</style></head><body>--&gt;</body></html>", + "noQuirksBodyHtml": "<style><!--</style>--&gt;" + } + }, + { + "data": "<ul><li>A </li> <li>B</li></ul>", + "errors": [ + "(1,4): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ul": true, + "li": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ul", + "children": [ + { + "tag": "li", + "children": [ + { + "text": "A " + } + ] + }, + { + "text": " " + }, + { + "tag": "li", + "children": [ + { + "text": "B" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ul><li>A </li> <li>B</li></ul></body></html>", + "noQuirksBodyHtml": "<ul><li>A </li> <li>B</li></ul>" + } + }, + { + "data": "<table><form><input type=hidden><input></form><div></div></table>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,13): unexpected-form-in-table", + "(1,32): unexpected-hidden-input-in-table", + "(1,39): unexpected-start-tag-implies-table-voodoo", + "(1,46): unexpected-end-tag-implies-table-voodoo", + "(1,46): unexpected-end-tag", + "(1,51): unexpected-start-tag-implies-table-voodoo", + "(1,57): unexpected-end-tag-implies-table-voodoo" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "input": true, + "div": true, + "table": true, + "form": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "input" + }, + { + "tag": "div" + }, + { + "tag": "table", + "children": [ + { + "tag": "form" + }, + { + "tag": "input", + "attrs": [ + { + "name": "type", + "value": "hidden" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><input><div></div><table><form></form><input type=\"hidden\"></table></body></html>", + "noQuirksBodyHtml": "<input><div></div><table><form></form><input type=\"hidden\"></table>" + } + }, + { + "data": "<i>A<b>B<p></i>C</b>D", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,15): adoption-agency-1.3", + "(1,20): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "i": true, + "b": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "i", + "children": [ + { + "text": "A" + }, + { + "tag": "b", + "children": [ + { + "text": "B" + } + ] + } + ] + }, + { + "tag": "b" + }, + { + "tag": "p", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "i" + }, + { + "text": "C" + } + ] + }, + { + "text": "D" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><i>A<b>B</b></i><b></b><p><b><i></i>C</b>D</p></body></html>", + "noQuirksBodyHtml": "<i>A<b>B</b></i><b></b><p><b><i></i>C</b>D</p>" + } + }, + { + "data": "<div></div>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div></div></body></html>", + "noQuirksBodyHtml": "<div></div>" + } + }, + { + "data": "<svg></svg>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg></svg></body></html>", + "noQuirksBodyHtml": "<svg></svg>" + } + }, + { + "data": "<math></math>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math></math></body></html>", + "noQuirksBodyHtml": "<math></math>" + } + } + ], + "inbody01.dat": [ + { + "data": "<button>1</foo>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,15): unexpected-end-tag", + "(1,15): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "button": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "button", + "children": [ + { + "text": "1" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><button>1</button></body></html>", + "noQuirksBodyHtml": "<button>1</button>" + } + }, + { + "data": "<foo>1<p>2</foo>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,16): unexpected-end-tag", + "(1,16): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "foo": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "foo", + "children": [ + { + "text": "1" + }, + { + "tag": "p", + "children": [ + { + "text": "2" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><foo>1<p>2</p></foo></body></html>", + "noQuirksBodyHtml": "<foo>1<p>2</p></foo>" + } + }, + { + "data": "<dd>1</foo>", + "errors": [ + "(1,4): expected-doctype-but-got-start-tag", + "(1,11): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "dd": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "dd", + "children": [ + { + "text": "1" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><dd>1</dd></body></html>", + "noQuirksBodyHtml": "<dd>1</dd>" + } + }, + { + "data": "<foo>1<dd>2</foo>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,17): unexpected-end-tag", + "(1,17): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "foo": true, + "dd": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "foo", + "children": [ + { + "text": "1" + }, + { + "tag": "dd", + "children": [ + { + "text": "2" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><foo>1<dd>2</dd></foo></body></html>", + "noQuirksBodyHtml": "<foo>1<dd>2</dd></foo>" + } + } + ], + "isindex.dat": [ + { + "data": "<isindex>", + "errors": [ + "(1,9): expected-doctype-but-got-start-tag", + "(1,9): deprecated-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "form": true, + "hr": true, + "label": true, + "input": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "form", + "children": [ + { + "tag": "hr" + }, + { + "tag": "label", + "children": [ + { + "text": "This is a searchable index. Enter search keywords: " + }, + { + "tag": "input", + "attrs": [ + { + "name": "name", + "value": "isindex" + } + ] + } + ] + }, + { + "tag": "hr" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><form><hr><label>This is a searchable index. Enter search keywords: <input name=\"isindex\"></label><hr></form></body></html>", + "noQuirksBodyHtml": "<form><hr><label>This is a searchable index. Enter search keywords: <input name=\"isindex\"></label><hr></form>" + } + }, + { + "data": "<isindex name=\"A\" action=\"B\" prompt=\"C\" foo=\"D\">", + "errors": [ + "(1,48): expected-doctype-but-got-start-tag", + "(1,48): deprecated-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "form": true, + "hr": true, + "label": true, + "input": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "form", + "attrs": [ + { + "name": "action", + "value": "B" + } + ], + "children": [ + { + "tag": "hr" + }, + { + "tag": "label", + "children": [ + { + "text": "C" + }, + { + "tag": "input", + "attrs": [ + { + "name": "foo", + "value": "D" + }, + { + "name": "name", + "value": "isindex" + } + ] + } + ] + }, + { + "tag": "hr" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><form action=\"B\"><hr><label>C<input name=\"isindex\" foo=\"D\"></label><hr></form></body></html>", + "noQuirksBodyHtml": "<form action=\"B\"><hr><label>C<input name=\"isindex\" foo=\"D\"></label><hr></form>" + } + }, + { + "data": "<form><isindex>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,15): deprecated-tag", + "(1,15): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "form": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "form" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><form></form></body></html>", + "noQuirksBodyHtml": "<form></form>" + } + } + ], + "main-element.dat": [ + { + "data": "<!doctype html><p>foo<main>bar<p>baz", + "errors": [ + "(1,36): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "main": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "main", + "children": [ + { + "text": "bar" + }, + { + "tag": "p", + "children": [ + { + "text": "baz" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p>foo</p><main>bar<p>baz</p></main></body></html>", + "noQuirksBodyHtml": "<p>foo</p><main>bar<p>baz</p></main>" + } + }, + { + "data": "<!doctype html><main><p>foo</main>bar", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "main": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "main", + "children": [ + { + "tag": "p", + "children": [ + { + "text": "foo" + } + ] + } + ] + }, + { + "text": "bar" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><main><p>foo</p></main>bar</body></html>", + "noQuirksBodyHtml": "<main><p>foo</p></main>bar" + } + }, + { + "data": "<!DOCTYPE html>xxx<svg><x><g><a><main><b>", + "errors": [ + " * (1,42) unexpected HTML-like start tag token in foreign content", + " * (1,42) unexpected end of file" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg x": true, + "svg g": true, + "svg a": true, + "svg main": true, + "b": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "xxx" + }, + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "x", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "a", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "main", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "b" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>xxx<svg><x><g><a><main></main></a></g></x></svg><b></b></body></html>", + "noQuirksBodyHtml": "xxx<svg><x><g><a><main><b></b></main></a></g></x></svg>" + } + } + ], + "math.dat": [ + { + "data": "<math><tr><td><mo><tr>", + "errors": [], + "fragment": { + "name": "td" + }, + "document": { + "props": { + "tags": { + "math math": true, + "math tr": true, + "math td": true, + "math mo": true + } + }, + "tree": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "tr", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "td", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mo", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ], + "html": "<math><tr><td><mo></mo></td></tr></math>", + "noQuirksBodyHtml": "<math><tr><td><mo></mo></td></tr></math>" + } + }, + { + "data": "<math><tr><td><mo><tr>", + "errors": [], + "fragment": { + "name": "tr" + }, + "document": { + "props": { + "tags": { + "math math": true, + "math tr": true, + "math td": true, + "math mo": true + } + }, + "tree": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "tr", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "td", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mo", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ], + "html": "<math><tr><td><mo></mo></td></tr></math>", + "noQuirksBodyHtml": "<math><tr><td><mo></mo></td></tr></math>" + } + }, + { + "data": "<math><thead><mo><tbody>", + "errors": [], + "fragment": { + "name": "thead" + }, + "document": { + "props": { + "tags": { + "math math": true, + "math thead": true, + "math mo": true + } + }, + "tree": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "thead", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mo", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ], + "html": "<math><thead><mo></mo></thead></math>", + "noQuirksBodyHtml": "<math><thead><mo></mo></thead></math>" + } + }, + { + "data": "<math><tfoot><mo><tbody>", + "errors": [], + "fragment": { + "name": "tfoot" + }, + "document": { + "props": { + "tags": { + "math math": true, + "math tfoot": true, + "math mo": true + } + }, + "tree": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "tfoot", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mo", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ], + "html": "<math><tfoot><mo></mo></tfoot></math>", + "noQuirksBodyHtml": "<math><tfoot><mo></mo></tfoot></math>" + } + }, + { + "data": "<math><tbody><mo><tfoot>", + "errors": [], + "fragment": { + "name": "tbody" + }, + "document": { + "props": { + "tags": { + "math math": true, + "math tbody": true, + "math mo": true + } + }, + "tree": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "tbody", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mo", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ], + "html": "<math><tbody><mo></mo></tbody></math>", + "noQuirksBodyHtml": "<math><tbody><mo></mo></tbody></math>" + } + }, + { + "data": "<math><tbody><mo></table>", + "errors": [], + "fragment": { + "name": "tbody" + }, + "document": { + "props": { + "tags": { + "math math": true, + "math tbody": true, + "math mo": true + } + }, + "tree": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "tbody", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mo", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ], + "html": "<math><tbody><mo></mo></tbody></math>", + "noQuirksBodyHtml": "<math><tbody><mo></mo></tbody></math>" + } + }, + { + "data": "<math><thead><mo></table>", + "errors": [], + "fragment": { + "name": "tbody" + }, + "document": { + "props": { + "tags": { + "math math": true, + "math thead": true, + "math mo": true + } + }, + "tree": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "thead", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mo", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ], + "html": "<math><thead><mo></mo></thead></math>", + "noQuirksBodyHtml": "<math><thead><mo></mo></thead></math>" + } + }, + { + "data": "<math><tfoot><mo></table>", + "errors": [], + "fragment": { + "name": "tbody" + }, + "document": { + "props": { + "tags": { + "math math": true, + "math tfoot": true, + "math mo": true + } + }, + "tree": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "tfoot", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mo", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ], + "html": "<math><tfoot><mo></mo></tfoot></math>", + "noQuirksBodyHtml": "<math><tfoot><mo></mo></tfoot></math>" + } + } + ], + "namespace-sensitivity.dat": [ + { + "data": "<body><table><tr><td><svg><td><foreignObject><span></td>Foo", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "svg svg": true, + "svg td": true, + "svg foreignObject": true, + "span": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Foo" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "td", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "span" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body>Foo<table><tbody><tr><td><svg><td><foreignObject><span></span></foreignObject></td></svg></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "Foo<table><tbody><tr><td><svg><td><foreignObject><span></span></foreignObject></td></svg></td></tr></tbody></table>" + } + } + ], + "pending-spec-changes-plain-text-unsafe.dat": [ + { + "data": "<body><table>\u0000filler\u0000text\u0000", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,14): invalid-codepoint", + "(1,14): invalid-codepoint-in-table-text", + "(1,21): invalid-codepoint", + "(1,21): invalid-codepoint-in-table-text", + "(1,26): invalid-codepoint", + "(1,26): invalid-codepoint-in-table-text", + "(1,26): foster-parenting-character-in-table", + "(1,26): foster-parenting-character-in-table", + "(1,26): foster-parenting-character-in-table", + "(1,26): foster-parenting-character-in-table", + "(1,26): foster-parenting-character-in-table", + "(1,26): foster-parenting-character-in-table", + "(1,26): foster-parenting-character-in-table", + "(1,26): foster-parenting-character-in-table", + "(1,26): foster-parenting-character-in-table", + "(1,26): foster-parenting-character-in-table", + "(1,26): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "fillertext" + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>fillertext<table></table></body></html>", + "noQuirksBodyHtml": "fillertext<table></table>" + } + } + ], + "pending-spec-changes.dat": [ + { + "data": "<input type=\"hidden\"><frameset>", + "errors": [ + "(1,21): expected-doctype-but-got-start-tag", + "(1,31): unexpected-start-tag", + "(1,31): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<input type=\"hidden\">" + } + }, + { + "data": "<!DOCTYPE html><table><caption><svg>foo</table>bar", + "errors": [ + "(1,47): unexpected-end-tag", + "(1,47): end-table-tag-in-caption" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "caption": true, + "svg svg": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "caption", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo" + } + ] + } + ] + } + ] + }, + { + "text": "bar" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><caption><svg>foo</svg></caption></table>bar</body></html>", + "noQuirksBodyHtml": "<table><caption><svg>foo</svg></caption></table>bar" + } + }, + { + "data": "<table><tr><td><svg><desc><td></desc><circle>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,30): unexpected-cell-end-tag", + "(1,37): unexpected-end-tag", + "(1,45): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "svg svg": true, + "svg desc": true, + "circle": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "desc", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + }, + { + "tag": "td", + "children": [ + { + "tag": "circle" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><td><svg><desc></desc></svg></td><td><circle></circle></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><svg><desc></desc></svg></td><td><circle></circle></td></tr></tbody></table>" + } + } + ], + "plain-text-unsafe.dat": [ + { + "data": "FOO&#x000D;ZOO", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,11): illegal-codepoint-for-numeric-entity" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO\rZOO" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO\rZOO</body></html>", + "noQuirksBodyHtml": "FOO\rZOO" + } + }, + { + "data": "<html>\u0000<frameset></frameset>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,7): invalid-codepoint", + "(1,7): invalid-codepoint-in-body", + "(1,17): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<html> \u0000 <frameset></frameset>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,8): invalid-codepoint", + "(1,8): invalid-codepoint-in-body", + "(1,19): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": " " + } + }, + { + "data": "<html>a\u0000a<frameset></frameset>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,8): invalid-codepoint", + "(1,8): invalid-codepoint-in-body", + "(1,19): unexpected-start-tag", + "(1,30): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "aa" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>aa</body></html>", + "noQuirksBodyHtml": "aa" + } + }, + { + "data": "<html>\u0000\u0000<frameset></frameset>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,7): invalid-codepoint", + "(1,7): invalid-codepoint-in-body", + "(1,8): invalid-codepoint", + "(1,8): invalid-codepoint-in-body", + "(1,18): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<html>\u0000\n <frameset></frameset>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,7): invalid-codepoint", + "(1,7): invalid-codepoint-in-body", + "(2,11): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "\n " + } + }, + { + "data": "<html><select>\u0000", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,15): invalid-codepoint", + "(1,15): invalid-codepoint-in-select", + "(1,15): eof-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><select></select></body></html>", + "noQuirksBodyHtml": "<select></select>" + } + }, + { + "data": "\u0000", + "errors": [ + "(1,1): invalid-codepoint", + "(1,1): expected-doctype-but-got-chars", + "(1,1): invalid-codepoint-in-body" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<body>\u0000", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,7): invalid-codepoint", + "(1,7): invalid-codepoint-in-body" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<plaintext>\u0000filler\u0000text\u0000", + "errors": [ + "(1,11): expected-doctype-but-got-start-tag", + "(1,12): invalid-codepoint", + "(1,19): invalid-codepoint", + "(1,24): invalid-codepoint", + "(1,24): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "plaintext": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "plaintext", + "children": [ + { + "text": "�filler�text�", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><plaintext>�filler�text�</plaintext></body></html>", + "noQuirksBodyHtml": "<plaintext>�filler�text�</plaintext>" + } + }, + { + "data": "<svg><![CDATA[\u0000filler\u0000text\u0000]]>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,30): invalid-codepoint", + "(1,30): invalid-codepoint", + "(1,30): invalid-codepoint", + "(1,30): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "�filler�text�" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>�filler�text�</svg></body></html>", + "noQuirksBodyHtml": "<svg>�filler�text�</svg>" + } + }, + { + "data": "<body><!\u0000>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,8): expected-dashes-or-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "comment": "�" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><!--�--></body></html>", + "noQuirksBodyHtml": "<!--�-->" + } + }, + { + "data": "<body><!\u0000filler\u0000text>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,8): expected-dashes-or-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "comment": "�filler�text" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><!--�filler�text--></body></html>", + "noQuirksBodyHtml": "<!--�filler�text-->" + } + }, + { + "data": "<body><svg><foreignObject>\u0000filler\u0000text", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,27): invalid-codepoint", + "(1,27): invalid-codepoint-in-body", + "(1,34): invalid-codepoint", + "(1,34): invalid-codepoint-in-body", + "(1,38): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg foreignObject": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "fillertext" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg><foreignObject>fillertext</foreignObject></svg></body></html>", + "noQuirksBodyHtml": "<svg><foreignObject>fillertext</foreignObject></svg>" + } + }, + { + "data": "<svg>\u0000filler\u0000text", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,6): invalid-codepoint", + "(1,6): invalid-codepoint-in-foreign-content", + "(1,13): invalid-codepoint", + "(1,13): invalid-codepoint-in-foreign-content", + "(1,17): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "�filler�text" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>�filler�text</svg></body></html>", + "noQuirksBodyHtml": "<svg>�filler�text</svg>" + } + }, + { + "data": "<svg>\u0000<frameset>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,6): invalid-codepoint", + "(1,6): invalid-codepoint-in-foreign-content", + "(1,16): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "�" + }, + { + "tag": "frameset", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>�<frameset></frameset></svg></body></html>", + "noQuirksBodyHtml": "<svg>�<frameset></frameset></svg>" + } + }, + { + "data": "<svg>\u0000 <frameset>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,6): invalid-codepoint", + "(1,6): invalid-codepoint-in-foreign-content", + "(1,17): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "� " + }, + { + "tag": "frameset", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>� <frameset></frameset></svg></body></html>", + "noQuirksBodyHtml": "<svg>� <frameset></frameset></svg>" + } + }, + { + "data": "<svg>\u0000a<frameset>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,6): invalid-codepoint", + "(1,6): invalid-codepoint-in-foreign-content", + "(1,17): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "�a" + }, + { + "tag": "frameset", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>�a<frameset></frameset></svg></body></html>", + "noQuirksBodyHtml": "<svg>�a<frameset></frameset></svg>" + } + }, + { + "data": "<svg>\u0000</svg><frameset>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,6): invalid-codepoint", + "(1,6): invalid-codepoint-in-foreign-content", + "(1,22): unexpected-start-tag", + "(1,22): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<svg>�</svg>" + } + }, + { + "data": "<svg>\u0000 </svg><frameset>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,6): invalid-codepoint", + "(1,6): invalid-codepoint-in-foreign-content", + "(1,23): unexpected-start-tag", + "(1,23): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<svg>� </svg>" + } + }, + { + "data": "<svg>\u0000a</svg><frameset>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,6): invalid-codepoint", + "(1,6): invalid-codepoint-in-foreign-content", + "(1,23): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "�a" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>�a</svg></body></html>", + "noQuirksBodyHtml": "<svg>�a</svg>" + } + }, + { + "data": "<svg><path></path></svg><frameset>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,34): unexpected-start-tag", + "(1,34): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<svg><path></path></svg>" + } + }, + { + "data": "<svg><p><frameset>", + "errors": [ + "(1, 5) expected-doctype-but-got-start-tag", + "(1, 8) unexpected-html-element-in-foreign-content", + "(1, 18) unexpected-start-tag", + "(1, 18) eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<svg><p><frameset></frameset></p></svg>" + } + }, + { + "data": "<!DOCTYPE html><pre>\r\n\r\nA</pre>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "pre": true + }, + "doctype": true, + "extraNL": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "pre", + "children": [ + { + "text": "\nA", + "extraNL": true + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><pre>\n\nA</pre></body></html>", + "noQuirksBodyHtml": "<pre>\n\nA</pre>" + } + }, + { + "data": "<!DOCTYPE html><pre>\r\rA</pre>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "pre": true + }, + "doctype": true, + "extraNL": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "pre", + "children": [ + { + "text": "\nA", + "extraNL": true + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><pre>\n\nA</pre></body></html>", + "noQuirksBodyHtml": "<pre>\n\nA</pre>" + } + }, + { + "data": "<!DOCTYPE html><pre>\rA</pre>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "pre": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "pre", + "children": [ + { + "text": "A" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><pre>A</pre></body></html>", + "noQuirksBodyHtml": "<pre>A</pre>" + } + }, + { + "data": "<!DOCTYPE html><table><tr><td><math><mtext>\u0000a", + "errors": [ + "(1,44): invalid-codepoint", + "(1,44): invalid-codepoint-in-body", + "(1,45): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "math math": true, + "math mtext": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mtext", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><math><mtext>a</mtext></math></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><math><mtext>a</mtext></math></td></tr></tbody></table>" + } + }, + { + "data": "<!DOCTYPE html><table><tr><td><svg><foreignObject>\u0000a", + "errors": [ + "(1,51): invalid-codepoint", + "(1,51): invalid-codepoint-in-body", + "(1,52): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "svg svg": true, + "svg foreignObject": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><svg><foreignObject>a</foreignObject></svg></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><svg><foreignObject>a</foreignObject></svg></td></tr></tbody></table>" + } + }, + { + "data": "<!DOCTYPE html><math><mi>a\u0000b", + "errors": [ + "(1,27): invalid-codepoint", + "(1,27): invalid-codepoint-in-body", + "(1,28): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mi": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "ab" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math><mi>ab</mi></math></body></html>", + "noQuirksBodyHtml": "<math><mi>ab</mi></math>" + } + }, + { + "data": "<!DOCTYPE html><math><mo>a\u0000b", + "errors": [ + "(1,27): invalid-codepoint", + "(1,27): invalid-codepoint-in-body", + "(1,28): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mo": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mo", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "ab" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math><mo>ab</mo></math></body></html>", + "noQuirksBodyHtml": "<math><mo>ab</mo></math>" + } + }, + { + "data": "<!DOCTYPE html><math><mn>a\u0000b", + "errors": [ + "(1,27): invalid-codepoint", + "(1,27): invalid-codepoint-in-body", + "(1,28): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mn": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mn", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "ab" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math><mn>ab</mn></math></body></html>", + "noQuirksBodyHtml": "<math><mn>ab</mn></math>" + } + }, + { + "data": "<!DOCTYPE html><math><ms>a\u0000b", + "errors": [ + "(1,27): invalid-codepoint", + "(1,27): invalid-codepoint-in-body", + "(1,28): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math ms": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "ms", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "ab" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math><ms>ab</ms></math></body></html>", + "noQuirksBodyHtml": "<math><ms>ab</ms></math>" + } + }, + { + "data": "<!DOCTYPE html><math><mtext>a\u0000b", + "errors": [ + "(1,30): invalid-codepoint", + "(1,30): invalid-codepoint-in-body", + "(1,31): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mtext": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mtext", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "ab" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math><mtext>ab</mtext></math></body></html>", + "noQuirksBodyHtml": "<math><mtext>ab</mtext></math>" + } + } + ], + "ruby.dat": [ + { + "data": "<html><ruby>a<rb>b<rb></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rb": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rb", + "children": [ + { + "text": "b" + } + ] + }, + { + "tag": "rb" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rb>b</rb><rb></rb></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rb>b</rb><rb></rb></ruby>" + } + }, + { + "data": "<html><ruby>a<rb>b<rt></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rb": true, + "rt": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rb", + "children": [ + { + "text": "b" + } + ] + }, + { + "tag": "rt" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rb>b</rb><rt></rt></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rb>b</rb><rt></rt></ruby>" + } + }, + { + "data": "<html><ruby>a<rb>b<rtc></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rb": true, + "rtc": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rb", + "children": [ + { + "text": "b" + } + ] + }, + { + "tag": "rtc" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rb>b</rb><rtc></rtc></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rb>b</rb><rtc></rtc></ruby>" + } + }, + { + "data": "<html><ruby>a<rb>b<rp></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rb": true, + "rp": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rb", + "children": [ + { + "text": "b" + } + ] + }, + { + "tag": "rp" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rb>b</rb><rp></rp></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rb>b</rb><rp></rp></ruby>" + } + }, + { + "data": "<html><ruby>a<rb>b<span></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rb": true, + "span": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rb", + "children": [ + { + "text": "b" + }, + { + "tag": "span" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rb>b<span></span></rb></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rb>b<span></span></rb></ruby>" + } + }, + { + "data": "<html><ruby>a<rt>b<rb></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rt": true, + "rb": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rt", + "children": [ + { + "text": "b" + } + ] + }, + { + "tag": "rb" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rt>b</rt><rb></rb></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rt>b</rt><rb></rb></ruby>" + } + }, + { + "data": "<html><ruby>a<rt>b<rt></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rt": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rt", + "children": [ + { + "text": "b" + } + ] + }, + { + "tag": "rt" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rt>b</rt><rt></rt></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rt>b</rt><rt></rt></ruby>" + } + }, + { + "data": "<html><ruby>a<rt>b<rtc></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rt": true, + "rtc": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rt", + "children": [ + { + "text": "b" + } + ] + }, + { + "tag": "rtc" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rt>b</rt><rtc></rtc></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rt>b</rt><rtc></rtc></ruby>" + } + }, + { + "data": "<html><ruby>a<rt>b<rp></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rt": true, + "rp": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rt", + "children": [ + { + "text": "b" + } + ] + }, + { + "tag": "rp" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rt>b</rt><rp></rp></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rt>b</rt><rp></rp></ruby>" + } + }, + { + "data": "<html><ruby>a<rt>b<span></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rt": true, + "span": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rt", + "children": [ + { + "text": "b" + }, + { + "tag": "span" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rt>b<span></span></rt></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rt>b<span></span></rt></ruby>" + } + }, + { + "data": "<html><ruby>a<rtc>b<rb></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rtc": true, + "rb": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rtc", + "children": [ + { + "text": "b" + } + ] + }, + { + "tag": "rb" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rtc>b</rtc><rb></rb></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rtc>b</rtc><rb></rb></ruby>" + } + }, + { + "data": "<html><ruby>a<rtc>b<rt>c<rt>d</ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rtc": true, + "rt": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rtc", + "children": [ + { + "text": "b" + }, + { + "tag": "rt", + "children": [ + { + "text": "c" + } + ] + }, + { + "tag": "rt", + "children": [ + { + "text": "d" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rtc>b<rt>c</rt><rt>d</rt></rtc></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rtc>b<rt>c</rt><rt>d</rt></rtc></ruby>" + } + }, + { + "data": "<html><ruby>a<rtc>b<rtc></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rtc": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rtc", + "children": [ + { + "text": "b" + } + ] + }, + { + "tag": "rtc" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rtc>b</rtc><rtc></rtc></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rtc>b</rtc><rtc></rtc></ruby>" + } + }, + { + "data": "<html><ruby>a<rtc>b<rp></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rtc": true, + "rp": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rtc", + "children": [ + { + "text": "b" + }, + { + "tag": "rp" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rtc>b<rp></rp></rtc></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rtc>b<rp></rp></rtc></ruby>" + } + }, + { + "data": "<html><ruby>a<rtc>b<span></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rtc": true, + "span": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rtc", + "children": [ + { + "text": "b" + }, + { + "tag": "span" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rtc>b<span></span></rtc></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rtc>b<span></span></rtc></ruby>" + } + }, + { + "data": "<html><ruby>a<rp>b<rb></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rp": true, + "rb": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rp", + "children": [ + { + "text": "b" + } + ] + }, + { + "tag": "rb" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rp>b</rp><rb></rb></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rp>b</rp><rb></rb></ruby>" + } + }, + { + "data": "<html><ruby>a<rp>b<rt></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rp": true, + "rt": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rp", + "children": [ + { + "text": "b" + } + ] + }, + { + "tag": "rt" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rp>b</rp><rt></rt></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rp>b</rp><rt></rt></ruby>" + } + }, + { + "data": "<html><ruby>a<rp>b<rtc></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rp": true, + "rtc": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rp", + "children": [ + { + "text": "b" + } + ] + }, + { + "tag": "rtc" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rp>b</rp><rtc></rtc></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rp>b</rp><rtc></rtc></ruby>" + } + }, + { + "data": "<html><ruby>a<rp>b<rp></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rp": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rp", + "children": [ + { + "text": "b" + } + ] + }, + { + "tag": "rp" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rp>b</rp><rp></rp></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rp>b</rp><rp></rp></ruby>" + } + }, + { + "data": "<html><ruby>a<rp>b<span></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rp": true, + "span": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rp", + "children": [ + { + "text": "b" + }, + { + "tag": "span" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rp>b<span></span></rp></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rp>b<span></span></rp></ruby>" + } + }, + { + "data": "<html><ruby><rtc><ruby>a<rb>b<rt></ruby></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rtc": true, + "rb": true, + "rt": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "tag": "rtc", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rb", + "children": [ + { + "text": "b" + } + ] + }, + { + "tag": "rt" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby><rtc><ruby>a<rb>b</rb><rt></rt></ruby></rtc></ruby></body></html>", + "noQuirksBodyHtml": "<ruby><rtc><ruby>a<rb>b</rb><rt></rt></ruby></rtc></ruby>" + } + } + ], + "scriptdata01.dat": [ + { + "data": "FOO<script>'Hello'</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "children": [ + { + "text": "'Hello'", + "no_escape": true + } + ] + }, + { + "text": "BAR" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script>'Hello'</script>BAR</body></html>", + "noQuirksBodyHtml": "FOO<script>'Hello'</script>BAR" + } + }, + { + "data": "FOO<script></script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script" + }, + { + "text": "BAR" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script></script>BAR</body></html>", + "noQuirksBodyHtml": "FOO<script></script>BAR" + } + }, + { + "data": "FOO<script></script >BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script" + }, + { + "text": "BAR" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script></script>BAR</body></html>", + "noQuirksBodyHtml": "FOO<script></script>BAR" + } + }, + { + "data": "FOO<script></script/>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,21): self-closing-flag-on-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script" + }, + { + "text": "BAR" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script></script>BAR</body></html>", + "noQuirksBodyHtml": "FOO<script></script>BAR" + } + }, + { + "data": "FOO<script></script/ >BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,20): unexpected-character-after-solidus-in-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script" + }, + { + "text": "BAR" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script></script>BAR</body></html>", + "noQuirksBodyHtml": "FOO<script></script>BAR" + } + }, + { + "data": "FOO<script type=\"text/plain\"></scriptx>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,42): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "attrs": [ + { + "name": "type", + "value": "text/plain" + } + ], + "children": [ + { + "text": "</scriptx>BAR", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script type=\"text/plain\"></scriptx>BAR</script></body></html>", + "noQuirksBodyHtml": "FOO<script type=\"text/plain\"></scriptx>BAR</script>" + } + }, + { + "data": "FOO<script></script foo=\">\" dd>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,31): attributes-in-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script" + }, + { + "text": "BAR" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script></script>BAR</body></html>", + "noQuirksBodyHtml": "FOO<script></script>BAR" + } + }, + { + "data": "FOO<script>'<'</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "children": [ + { + "text": "'<'", + "no_escape": true + } + ] + }, + { + "text": "BAR" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script>'<'</script>BAR</body></html>", + "noQuirksBodyHtml": "FOO<script>'<'</script>BAR" + } + }, + { + "data": "FOO<script>'<!'</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "children": [ + { + "text": "'<!'", + "no_escape": true + } + ] + }, + { + "text": "BAR" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script>'<!'</script>BAR</body></html>", + "noQuirksBodyHtml": "FOO<script>'<!'</script>BAR" + } + }, + { + "data": "FOO<script>'<!-'</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "children": [ + { + "text": "'<!-'", + "no_escape": true + } + ] + }, + { + "text": "BAR" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script>'<!-'</script>BAR</body></html>", + "noQuirksBodyHtml": "FOO<script>'<!-'</script>BAR" + } + }, + { + "data": "FOO<script>'<!--'</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "children": [ + { + "text": "'<!--'", + "no_escape": true + } + ] + }, + { + "text": "BAR" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script>'<!--'</script>BAR</body></html>", + "noQuirksBodyHtml": "FOO<script>'<!--'</script>BAR" + } + }, + { + "data": "FOO<script>'<!---'</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "children": [ + { + "text": "'<!---'", + "no_escape": true + } + ] + }, + { + "text": "BAR" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script>'<!---'</script>BAR</body></html>", + "noQuirksBodyHtml": "FOO<script>'<!---'</script>BAR" + } + }, + { + "data": "FOO<script>'<!-->'</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "children": [ + { + "text": "'<!-->'", + "no_escape": true + } + ] + }, + { + "text": "BAR" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script>'<!-->'</script>BAR</body></html>", + "noQuirksBodyHtml": "FOO<script>'<!-->'</script>BAR" + } + }, + { + "data": "FOO<script>'<!-->'</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "children": [ + { + "text": "'<!-->'", + "no_escape": true + } + ] + }, + { + "text": "BAR" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script>'<!-->'</script>BAR</body></html>", + "noQuirksBodyHtml": "FOO<script>'<!-->'</script>BAR" + } + }, + { + "data": "FOO<script>'<!-- potato'</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "children": [ + { + "text": "'<!-- potato'", + "no_escape": true + } + ] + }, + { + "text": "BAR" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script>'<!-- potato'</script>BAR</body></html>", + "noQuirksBodyHtml": "FOO<script>'<!-- potato'</script>BAR" + } + }, + { + "data": "FOO<script>'<!-- <sCrIpt'</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "children": [ + { + "text": "'<!-- <sCrIpt'", + "no_escape": true + } + ] + }, + { + "text": "BAR" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script>'<!-- <sCrIpt'</script>BAR</body></html>", + "noQuirksBodyHtml": "FOO<script>'<!-- <sCrIpt'</script>BAR" + } + }, + { + "data": "FOO<script type=\"text/plain\">'<!-- <sCrIpt>'</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,56): expected-script-data-but-got-eof", + "(1,56): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "attrs": [ + { + "name": "type", + "value": "text/plain" + } + ], + "children": [ + { + "text": "'<!-- <sCrIpt>'</script>BAR", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script type=\"text/plain\">'<!-- <sCrIpt>'</script>BAR</script></body></html>", + "noQuirksBodyHtml": "FOO<script type=\"text/plain\">'<!-- <sCrIpt>'</script>BAR</script>" + } + }, + { + "data": "FOO<script type=\"text/plain\">'<!-- <sCrIpt> -'</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,58): expected-script-data-but-got-eof", + "(1,58): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "attrs": [ + { + "name": "type", + "value": "text/plain" + } + ], + "children": [ + { + "text": "'<!-- <sCrIpt> -'</script>BAR", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script type=\"text/plain\">'<!-- <sCrIpt> -'</script>BAR</script></body></html>", + "noQuirksBodyHtml": "FOO<script type=\"text/plain\">'<!-- <sCrIpt> -'</script>BAR</script>" + } + }, + { + "data": "FOO<script type=\"text/plain\">'<!-- <sCrIpt> --'</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,59): expected-script-data-but-got-eof", + "(1,59): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "attrs": [ + { + "name": "type", + "value": "text/plain" + } + ], + "children": [ + { + "text": "'<!-- <sCrIpt> --'</script>BAR", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script type=\"text/plain\">'<!-- <sCrIpt> --'</script>BAR</script></body></html>", + "noQuirksBodyHtml": "FOO<script type=\"text/plain\">'<!-- <sCrIpt> --'</script>BAR</script>" + } + }, + { + "data": "FOO<script>'<!-- <sCrIpt> -->'</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "children": [ + { + "text": "'<!-- <sCrIpt> -->'", + "no_escape": true + } + ] + }, + { + "text": "BAR" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script>'<!-- <sCrIpt> -->'</script>BAR</body></html>", + "noQuirksBodyHtml": "FOO<script>'<!-- <sCrIpt> -->'</script>BAR" + } + }, + { + "data": "FOO<script type=\"text/plain\">'<!-- <sCrIpt> --!>'</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,61): expected-script-data-but-got-eof", + "(1,61): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "attrs": [ + { + "name": "type", + "value": "text/plain" + } + ], + "children": [ + { + "text": "'<!-- <sCrIpt> --!>'</script>BAR", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script type=\"text/plain\">'<!-- <sCrIpt> --!>'</script>BAR</script></body></html>", + "noQuirksBodyHtml": "FOO<script type=\"text/plain\">'<!-- <sCrIpt> --!>'</script>BAR</script>" + } + }, + { + "data": "FOO<script type=\"text/plain\">'<!-- <sCrIpt> -- >'</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,61): expected-script-data-but-got-eof", + "(1,61): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "attrs": [ + { + "name": "type", + "value": "text/plain" + } + ], + "children": [ + { + "text": "'<!-- <sCrIpt> -- >'</script>BAR", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script type=\"text/plain\">'<!-- <sCrIpt> -- >'</script>BAR</script></body></html>", + "noQuirksBodyHtml": "FOO<script type=\"text/plain\">'<!-- <sCrIpt> -- >'</script>BAR</script>" + } + }, + { + "data": "FOO<script type=\"text/plain\">'<!-- <sCrIpt '</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,56): expected-script-data-but-got-eof", + "(1,56): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "attrs": [ + { + "name": "type", + "value": "text/plain" + } + ], + "children": [ + { + "text": "'<!-- <sCrIpt '</script>BAR", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script type=\"text/plain\">'<!-- <sCrIpt '</script>BAR</script></body></html>", + "noQuirksBodyHtml": "FOO<script type=\"text/plain\">'<!-- <sCrIpt '</script>BAR</script>" + } + }, + { + "data": "FOO<script type=\"text/plain\">'<!-- <sCrIpt/'</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars", + "(1,56): expected-script-data-but-got-eof", + "(1,56): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "attrs": [ + { + "name": "type", + "value": "text/plain" + } + ], + "children": [ + { + "text": "'<!-- <sCrIpt/'</script>BAR", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script type=\"text/plain\">'<!-- <sCrIpt/'</script>BAR</script></body></html>", + "noQuirksBodyHtml": "FOO<script type=\"text/plain\">'<!-- <sCrIpt/'</script>BAR</script>" + } + }, + { + "data": "FOO<script type=\"text/plain\">'<!-- <sCrIpt\\'</script>BAR", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "attrs": [ + { + "name": "type", + "value": "text/plain" + } + ], + "children": [ + { + "text": "'<!-- <sCrIpt\\'", + "no_escape": true + } + ] + }, + { + "text": "BAR" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script type=\"text/plain\">'<!-- <sCrIpt\\'</script>BAR</body></html>", + "noQuirksBodyHtml": "FOO<script type=\"text/plain\">'<!-- <sCrIpt\\'</script>BAR" + } + }, + { + "data": "FOO<script type=\"text/plain\">'<!-- <sCrIpt/'</script>BAR</script>QUX", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "attrs": [ + { + "name": "type", + "value": "text/plain" + } + ], + "children": [ + { + "text": "'<!-- <sCrIpt/'</script>BAR", + "no_escape": true + } + ] + }, + { + "text": "QUX" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script type=\"text/plain\">'<!-- <sCrIpt/'</script>BAR</script>QUX</body></html>", + "noQuirksBodyHtml": "FOO<script type=\"text/plain\">'<!-- <sCrIpt/'</script>BAR</script>QUX" + } + }, + { + "data": "FOO<script><!--<script>-></script>--></script>QUX", + "errors": [ + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "FOO" + }, + { + "tag": "script", + "children": [ + { + "text": "<!--<script>-></script>-->", + "no_escape": true + } + ] + }, + { + "text": "QUX" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>FOO<script><!--<script>-></script>--></script>QUX</body></html>", + "noQuirksBodyHtml": "FOO<script><!--<script>-></script>--></script>QUX" + } + } + ], + "tables01.dat": [ + { + "data": "<table><th>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,11): unexpected-cell-in-table-body", + "(1,11): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "th": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "th" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><th></th></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><th></th></tr></tbody></table>" + } + }, + { + "data": "<table><td>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,11): unexpected-cell-in-table-body", + "(1,11): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><td></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td></td></tr></tbody></table>" + } + }, + { + "data": "<table><col foo='bar'>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,22): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "colgroup": true, + "col": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "colgroup", + "children": [ + { + "tag": "col", + "attrs": [ + { + "name": "foo", + "value": "bar" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><colgroup><col foo=\"bar\"></colgroup></table></body></html>", + "noQuirksBodyHtml": "<table><colgroup><col foo=\"bar\"></colgroup></table>" + } + }, + { + "data": "<table><colgroup></html>foo", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,24): unexpected-end-tag", + "(1,27): foster-parenting-character-in-table", + "(1,27): foster-parenting-character-in-table", + "(1,27): foster-parenting-character-in-table", + "(1,27): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "colgroup": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "foo" + }, + { + "tag": "table", + "children": [ + { + "tag": "colgroup" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body>foo<table><colgroup></colgroup></table></body></html>", + "noQuirksBodyHtml": "foo<table><colgroup></colgroup></table>" + } + }, + { + "data": "<table></table><p>foo", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table" + }, + { + "tag": "p", + "children": [ + { + "text": "foo" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table></table><p>foo</p></body></html>", + "noQuirksBodyHtml": "<table></table><p>foo</p>" + } + }, + { + "data": "<table></body></caption></col></colgroup></html></tbody></td></tfoot></th></thead></tr><td>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,14): unexpected-end-tag", + "(1,24): unexpected-end-tag", + "(1,30): unexpected-end-tag", + "(1,41): unexpected-end-tag", + "(1,48): unexpected-end-tag", + "(1,56): unexpected-end-tag", + "(1,61): unexpected-end-tag", + "(1,69): unexpected-end-tag", + "(1,74): unexpected-end-tag", + "(1,82): unexpected-end-tag", + "(1,87): unexpected-end-tag", + "(1,91): unexpected-cell-in-table-body", + "(1,91): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><td></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td></td></tr></tbody></table>" + } + }, + { + "data": "<table><select><option>3</select></table>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,15): unexpected-start-tag-implies-table-voodoo" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "option": true, + "table": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "option", + "children": [ + { + "text": "3" + } + ] + } + ] + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><select><option>3</option></select><table></table></body></html>", + "noQuirksBodyHtml": "<select><option>3</option></select><table></table>" + } + }, + { + "data": "<table><select><table></table></select></table>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,15): unexpected-start-tag-implies-table-voodoo", + "(1,22): unexpected-table-element-start-tag-in-select-in-table", + "(1,22): unexpected-start-tag-implies-end-tag", + "(1,39): unexpected-end-tag", + "(1,47): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "table": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + }, + { + "tag": "table" + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><select></select><table></table><table></table></body></html>", + "noQuirksBodyHtml": "<select></select><table></table><table></table>" + } + }, + { + "data": "<table><select></table>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,15): unexpected-start-tag-implies-table-voodoo", + "(1,23): unexpected-table-element-end-tag-in-select-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "table": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><select></select><table></table></body></html>", + "noQuirksBodyHtml": "<select></select><table></table>" + } + }, + { + "data": "<table><select><option>A<tr><td>B</td></tr></table>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,15): unexpected-start-tag-implies-table-voodoo", + "(1,28): unexpected-table-element-start-tag-in-select-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "option": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "option", + "children": [ + { + "text": "A" + } + ] + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "text": "B" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><select><option>A</option></select><table><tbody><tr><td>B</td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<select><option>A</option></select><table><tbody><tr><td>B</td></tr></tbody></table>" + } + }, + { + "data": "<table><td></body></caption></col></colgroup></html>foo", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,11): unexpected-cell-in-table-body", + "(1,18): unexpected-end-tag", + "(1,28): unexpected-end-tag", + "(1,34): unexpected-end-tag", + "(1,45): unexpected-end-tag", + "(1,52): unexpected-end-tag", + "(1,55): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "text": "foo" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><td>foo</td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td>foo</td></tr></tbody></table>" + } + }, + { + "data": "<table><td>A</table>B", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,11): unexpected-cell-in-table-body" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "text": "A" + } + ] + } + ] + } + ] + } + ] + }, + { + "text": "B" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><td>A</td></tr></tbody></table>B</body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td>A</td></tr></tbody></table>B" + } + }, + { + "data": "<table><tr><caption>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,20): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "caption": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + }, + { + "tag": "caption" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr></tr></tbody><caption></caption></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr></tr></tbody><caption></caption></table>" + } + }, + { + "data": "<table><tr></body></caption></col></colgroup></html></td></th><td>foo", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,18): unexpected-end-tag-in-table-row", + "(1,28): unexpected-end-tag-in-table-row", + "(1,34): unexpected-end-tag-in-table-row", + "(1,45): unexpected-end-tag-in-table-row", + "(1,52): unexpected-end-tag-in-table-row", + "(1,57): unexpected-end-tag-in-table-row", + "(1,62): unexpected-end-tag-in-table-row", + "(1,69): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "text": "foo" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><td>foo</td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td>foo</td></tr></tbody></table>" + } + }, + { + "data": "<table><td><tr>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,11): unexpected-cell-in-table-body", + "(1,15): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + }, + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><td></td></tr><tr></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td></td></tr><tr></tr></tbody></table>" + } + }, + { + "data": "<table><td><button><td>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,11): unexpected-cell-in-table-body", + "(1,23): unexpected-cell-end-tag", + "(1,23): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "button": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "button" + } + ] + }, + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><td><button></button></td><td></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><button></button></td><td></td></tr></tbody></table>" + } + }, + { + "data": "<table><tr><td><svg><desc><td>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,30): unexpected-cell-end-tag", + "(1,30): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "svg svg": true, + "svg desc": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "desc", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + }, + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><td><svg><desc></desc></svg></td><td></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><svg><desc></desc></svg></td><td></td></tr></tbody></table>" + } + } + ], + "template.dat": [ + { + "data": "<body><template>Hello</template>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template>Hello</template></body></html>", + "noQuirksBodyHtml": "<template>Hello</template>" + } + }, + { + "data": "<template>Hello</template>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template>Hello</template></head><body></body></html>", + "noQuirksBodyHtml": "<template>Hello</template>" + } + }, + { + "data": "<template></template><div></div>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "body": true, + "div": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ], + "html": "<html><head><template></template></head><body><div></div></body></html>", + "noQuirksBodyHtml": "<template></template><div></div>" + } + }, + { + "data": "<html><template>Hello</template>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template>Hello</template></head><body></body></html>", + "noQuirksBodyHtml": "<template>Hello</template>" + } + }, + { + "data": "<head><template><div></div></template></head>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "div": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><div></div></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><div></div></template>" + } + }, + { + "data": "<div><template><div><span></template><b>", + "errors": [ + " * (1,6) missing DOCTYPE", + " * (1,38) mismatched template end tag", + " * (1,41) unexpected end of file" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "template": true, + "span": true, + "b": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "span" + } + ] + } + ] + } + ] + }, + { + "tag": "b" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div><template><div><span></span></div></template><b></b></div></body></html>", + "noQuirksBodyHtml": "<div><template><div><span></span></div></template><b></b></div>" + } + }, + { + "data": "<div><template></div>Hello", + "errors": [ + " * (1,6) missing DOCTYPE", + " * (1,22) unexpected token in template", + " * (1,27) unexpected end of file in template", + " * (1,27) unexpected end of file" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "template": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div><template>Hello</template></div></body></html>", + "noQuirksBodyHtml": "<div><template>Hello</template></div>" + } + }, + { + "data": "<div></template></div>", + "errors": [ + " * (1,6) missing DOCTYPE", + " * (1,17) unexpected template end tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div></div></body></html>", + "noQuirksBodyHtml": "<div></div>" + } + }, + { + "data": "<table><template></template></table>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "template": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><template></template></table></body></html>", + "noQuirksBodyHtml": "<table><template></template></table>" + } + }, + { + "data": "<table><template></template></div>", + "errors": [ + " * (1,8) missing DOCTYPE", + " * (1,35) unexpected token in table - foster parenting", + " * (1,35) unexpected end tag", + " * (1,35) unexpected end of file" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "template": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><template></template></table></body></html>", + "noQuirksBodyHtml": "<table><template></template></table>" + } + }, + { + "data": "<table><div><template></template></div>", + "errors": [ + " * (1,8) missing DOCTYPE", + " * (1,13) unexpected token in table - foster parenting", + " * (1,40) unexpected token in table - foster parenting", + " * (1,40) unexpected end of file" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "template": true, + "table": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div><template></template></div><table></table></body></html>", + "noQuirksBodyHtml": "<div><template></template></div><table></table>" + } + }, + { + "data": "<table><template></template><div></div>", + "errors": [ + "no doctype", + "bad div in table", + "bad /div in table", + "eof in table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "table": true, + "template": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div" + }, + { + "tag": "table", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div></div><table><template></template></table></body></html>", + "noQuirksBodyHtml": "<div></div><table><template></template></table>" + } + }, + { + "data": "<table> <template></template></table>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "template": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "text": " " + }, + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table> <template></template></table></body></html>", + "noQuirksBodyHtml": "<table> <template></template></table>" + } + }, + { + "data": "<table><tbody><template></template></tbody>", + "errors": [ + "no doctype", + "eof in table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "template": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><template></template></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><template></template></tbody></table>" + } + }, + { + "data": "<table><tbody><template></tbody></template>", + "errors": [ + "no doctype", + "bad /tbody", + "eof in table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "template": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><template></template></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><template></template></tbody></table>" + } + }, + { + "data": "<table><tbody><template></template></tbody></table>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "template": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><template></template></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><template></template></tbody></table>" + } + }, + { + "data": "<table><thead><template></template></thead>", + "errors": [ + "no doctype", + "eof in table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "thead": true, + "template": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "thead", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><thead><template></template></thead></table></body></html>", + "noQuirksBodyHtml": "<table><thead><template></template></thead></table>" + } + }, + { + "data": "<table><tfoot><template></template></tfoot>", + "errors": [ + "no doctype", + "eof in table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tfoot": true, + "template": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tfoot", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tfoot><template></template></tfoot></table></body></html>", + "noQuirksBodyHtml": "<table><tfoot><template></template></tfoot></table>" + } + }, + { + "data": "<select><template></template></select>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "template": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><select><template></template></select></body></html>", + "noQuirksBodyHtml": "<select><template></template></select>" + } + }, + { + "data": "<select><template><option></option></template></select>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "template": true, + "option": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "option" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><select><template><option></option></template></select></body></html>", + "noQuirksBodyHtml": "<select><template><option></option></template></select>" + } + }, + { + "data": "<template><option></option></select><option></option></template>", + "errors": [ + "no doctype", + "bad /select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "option": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "option" + }, + { + "tag": "option" + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><option></option><option></option></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><option></option><option></option></template>" + } + }, + { + "data": "<select><template></template><option></select>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "template": true, + "option": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + }, + { + "tag": "option" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><select><template></template><option></option></select></body></html>", + "noQuirksBodyHtml": "<select><template></template><option></option></select>" + } + }, + { + "data": "<select><option><template></template></select>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "option": true, + "template": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "option", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><select><option><template></template></option></select></body></html>", + "noQuirksBodyHtml": "<select><option><template></template></option></select>" + } + }, + { + "data": "<select><template>", + "errors": [ + "no doctype", + "eof in template", + "eof in select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "template": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><select><template></template></select></body></html>", + "noQuirksBodyHtml": "<select><template></template></select>" + } + }, + { + "data": "<select><option></option><template>", + "errors": [ + "no doctype", + "eof in template", + "eof in select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "option": true, + "template": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "option" + }, + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><select><option></option><template></template></select></body></html>", + "noQuirksBodyHtml": "<select><option></option><template></template></select>" + } + }, + { + "data": "<select><option></option><template><option>", + "errors": [ + "no doctype", + "eof in template", + "eof in select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "option": true, + "template": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "option" + }, + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "option" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><select><option></option><template><option></option></template></select></body></html>", + "noQuirksBodyHtml": "<select><option></option><template><option></option></template></select>" + } + }, + { + "data": "<table><thead><template><td></template></table>", + "errors": [ + " * (1,8) missing DOCTYPE" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "thead": true, + "template": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "thead", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><thead><template><td></td></template></thead></table></body></html>", + "noQuirksBodyHtml": "<table><thead><template><td></td></template></thead></table>" + } + }, + { + "data": "<table><template><thead></template></table>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "template": true, + "thead": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "thead" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><template><thead></thead></template></table></body></html>", + "noQuirksBodyHtml": "<table><template><thead></thead></template></table>" + } + }, + { + "data": "<body><table><template><td></tr><div></template></table>", + "errors": [ + "no doctype", + "bad </tr>", + "missing </div>" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "template": true, + "td": true, + "div": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><template><td><div></div></td></template></table></body></html>", + "noQuirksBodyHtml": "<table><template><td><div></div></td></template></table>" + } + }, + { + "data": "<table><template><thead></template></thead></table>", + "errors": [ + "no doctype", + "bad /thead after /template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "template": true, + "thead": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "thead" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><template><thead></thead></template></table></body></html>", + "noQuirksBodyHtml": "<table><template><thead></thead></template></table>" + } + }, + { + "data": "<table><thead><template><tr></template></table>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "thead": true, + "template": true, + "tr": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "thead", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><thead><template><tr></tr></template></thead></table></body></html>", + "noQuirksBodyHtml": "<table><thead><template><tr></tr></template></thead></table>" + } + }, + { + "data": "<table><template><tr></template></table>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "template": true, + "tr": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><template><tr></tr></template></table></body></html>", + "noQuirksBodyHtml": "<table><template><tr></tr></template></table>" + } + }, + { + "data": "<table><tr><template><td>", + "errors": [ + "no doctype", + "eof in template", + "eof in table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "template": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><template><td></td></template></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><template><td></td></template></tr></tbody></table>" + } + }, + { + "data": "<table><template><tr><template><td></template></tr></template></table>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "template": true, + "tr": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><template><tr><template><td></td></template></tr></template></table></body></html>", + "noQuirksBodyHtml": "<table><template><tr><template><td></td></template></tr></template></table>" + } + }, + { + "data": "<table><template><tr><template><td></td></template></tr></template></table>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "template": true, + "tr": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><template><tr><template><td></td></template></tr></template></table></body></html>", + "noQuirksBodyHtml": "<table><template><tr><template><td></td></template></tr></template></table>" + } + }, + { + "data": "<table><template><td></template>", + "errors": [ + "no doctype", + "eof in table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "template": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><template><td></td></template></table></body></html>", + "noQuirksBodyHtml": "<table><template><td></td></template></table>" + } + }, + { + "data": "<body><template><td></td></template>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><td></td></template></body></html>", + "noQuirksBodyHtml": "<template><td></td></template>" + } + }, + { + "data": "<body><template><template><tr></tr></template><td></td></template>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "tr": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "tr" + } + ] + } + ] + }, + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><template><tr></tr></template><td></td></template></body></html>", + "noQuirksBodyHtml": "<template><template><tr></tr></template><td></td></template>" + } + }, + { + "data": "<table><colgroup><template><col>", + "errors": [ + "no doctype", + "eof in template", + "eof in table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "colgroup": true, + "template": true, + "col": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "colgroup", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "col" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><colgroup><template><col></template></colgroup></table></body></html>", + "noQuirksBodyHtml": "<table><colgroup><template><col></template></colgroup></table>" + } + }, + { + "data": "<frameset><template><frame></frame></template></frameset>", + "errors": [ + " * (1,11) missing DOCTYPE", + " * (1,21) unexpected start tag token", + " * (1,36) unexpected end tag token", + " * (1,47) unexpected end tag token" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true, + "frame": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset", + "children": [ + { + "tag": "frame" + } + ] + } + ] + } + ], + "html": "<html><head></head><frameset><frame></frameset></html>", + "noQuirksBodyHtml": "<template></template>" + } + }, + { + "data": "<template><frame></frame></frameset><frame></frame></template>", + "errors": [ + " * (1,11) missing DOCTYPE", + " * (1,18) unexpected start tag", + " * (1,26) unexpected end tag", + " * (1,37) unexpected end tag", + " * (1,44) unexpected start tag", + " * (1,52) unexpected end tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template></template></head><body></body></html>", + "noQuirksBodyHtml": "<template></template>" + } + }, + { + "data": "<template><div><frameset><span></span></div><span></span></template>", + "errors": [ + "no doctype", + "bad frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "div": true, + "span": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "span" + } + ] + }, + { + "tag": "span" + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><div><span></span></div><span></span></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><div><span></span></div><span></span></template>" + } + }, + { + "data": "<body><template><div><frameset><span></span></div><span></span></template></body>", + "errors": [ + "no doctype", + "bad frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "div": true, + "span": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "span" + } + ] + }, + { + "tag": "span" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><div><span></span></div><span></span></template></body></html>", + "noQuirksBodyHtml": "<template><div><span></span></div><span></span></template>" + } + }, + { + "data": "<body><template><script>var i = 1;</script><td></td></template>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "script": true, + "td": true + }, + "template": true, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "script", + "children": [ + { + "text": "var i = 1;", + "no_escape": true + } + ] + }, + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><script>var i = 1;</script><td></td></template></body></html>", + "noQuirksBodyHtml": "<template><script>var i = 1;</script><td></td></template>" + } + }, + { + "data": "<body><template><tr><div></div></tr></template>", + "errors": [ + "no doctype", + "foster-parented div", + "foster-parented /div" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "tr": true, + "div": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "tr" + }, + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><tr></tr><div></div></template></body></html>", + "noQuirksBodyHtml": "<template><tr></tr><div></div></template>" + } + }, + { + "data": "<body><template><tr></tr><td></td></template>", + "errors": [ + "no doctype", + "unexpected <td>" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "tr": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "tr" + }, + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><tr></tr><tr><td></td></tr></template></body></html>", + "noQuirksBodyHtml": "<template><tr></tr><tr><td></td></tr></template>" + } + }, + { + "data": "<body><template><td></td></tr><td></td></template>", + "errors": [ + "no doctype", + "bad </tr>" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "td" + }, + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><td></td><td></td></template></body></html>", + "noQuirksBodyHtml": "<template><td></td><td></td></template>" + } + }, + { + "data": "<body><template><td></td><tbody><td></td></template>", + "errors": [ + "no doctype", + "bad <tbody>" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "td" + }, + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><td></td><td></td></template></body></html>", + "noQuirksBodyHtml": "<template><td></td><td></td></template>" + } + }, + { + "data": "<body><template><td></td><caption></caption><td></td></template>", + "errors": [ + " * (1,7) missing DOCTYPE", + " * (1,35) unexpected start tag in table row", + " * (1,45) unexpected end tag in table row" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "td" + }, + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><td></td><td></td></template></body></html>", + "noQuirksBodyHtml": "<template><td></td><td></td></template>" + } + }, + { + "data": "<body><template><td></td><colgroup></caption><td></td></template>", + "errors": [ + " * (1,7) missing DOCTYPE", + " * (1,36) unexpected start tag in table row", + " * (1,46) unexpected end tag in table row" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "td" + }, + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><td></td><td></td></template></body></html>", + "noQuirksBodyHtml": "<template><td></td><td></td></template>" + } + }, + { + "data": "<body><template><td></td></table><td></td></template>", + "errors": [ + "no doctype", + "bad </table>" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "td" + }, + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><td></td><td></td></template></body></html>", + "noQuirksBodyHtml": "<template><td></td><td></td></template>" + } + }, + { + "data": "<body><template><tr></tr><tbody><tr></tr></template>", + "errors": [ + "no doctype", + "bad <tbody>" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "tr": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "tr" + }, + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><tr></tr><tr></tr></template></body></html>", + "noQuirksBodyHtml": "<template><tr></tr><tr></tr></template>" + } + }, + { + "data": "<body><template><tr></tr><caption><tr></tr></template>", + "errors": [ + "no doctype", + "bad <caption>" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "tr": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "tr" + }, + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><tr></tr><tr></tr></template></body></html>", + "noQuirksBodyHtml": "<template><tr></tr><tr></tr></template>" + } + }, + { + "data": "<body><template><tr></tr></table><tr></tr></template>", + "errors": [ + "no doctype", + "bad </table>" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "tr": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "tr" + }, + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><tr></tr><tr></tr></template></body></html>", + "noQuirksBodyHtml": "<template><tr></tr><tr></tr></template>" + } + }, + { + "data": "<body><template><thead></thead><caption></caption><tbody></tbody></template>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "thead": true, + "caption": true, + "tbody": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "thead" + }, + { + "tag": "caption" + }, + { + "tag": "tbody" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><thead></thead><caption></caption><tbody></tbody></template></body></html>", + "noQuirksBodyHtml": "<template><thead></thead><caption></caption><tbody></tbody></template>" + } + }, + { + "data": "<body><template><thead></thead></table><tbody></tbody></template></body>", + "errors": [ + "no doctype", + "bad </table>" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "thead": true, + "tbody": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "thead" + }, + { + "tag": "tbody" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><thead></thead><tbody></tbody></template></body></html>", + "noQuirksBodyHtml": "<template><thead></thead><tbody></tbody></template>" + } + }, + { + "data": "<body><template><div><tr></tr></div></template>", + "errors": [ + "no doctype", + "bad tr", + "bad /tr" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "div": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><div></div></template></body></html>", + "noQuirksBodyHtml": "<template><div></div></template>" + } + }, + { + "data": "<body><template><em>Hello</em></template>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "em": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "em", + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><em>Hello</em></template></body></html>", + "noQuirksBodyHtml": "<template><em>Hello</em></template>" + } + }, + { + "data": "<body><template><!--comment--></template>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true + }, + "template": true, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "comment": "comment" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><!--comment--></template></body></html>", + "noQuirksBodyHtml": "<template><!--comment--></template>" + } + }, + { + "data": "<body><template><style></style><td></td></template>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "style": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "style" + }, + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><style></style><td></td></template></body></html>", + "noQuirksBodyHtml": "<template><style></style><td></td></template>" + } + }, + { + "data": "<body><template><meta><td></td></template>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "meta": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "meta" + }, + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><meta><td></td></template></body></html>", + "noQuirksBodyHtml": "<template><meta><td></td></template>" + } + }, + { + "data": "<body><template><link><td></td></template>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "link": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "link" + }, + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><link><td></td></template></body></html>", + "noQuirksBodyHtml": "<template><link><td></td></template>" + } + }, + { + "data": "<body><template><template><tr></tr></template><td></td></template>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "tr": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "tr" + } + ] + } + ] + }, + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><template><tr></tr></template><td></td></template></body></html>", + "noQuirksBodyHtml": "<template><template><tr></tr></template><td></td></template>" + } + }, + { + "data": "<body><table><colgroup><template><col></col></template></colgroup></table></body>", + "errors": [ + "no doctype", + "bad /col" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "colgroup": true, + "template": true, + "col": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "colgroup", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "col" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><colgroup><template><col></template></colgroup></table></body></html>", + "noQuirksBodyHtml": "<table><colgroup><template><col></template></colgroup></table>" + } + }, + { + "data": "<body a=b><template><div></div><body c=d><div></div></body></template></body>", + "errors": [ + "no doctype", + "bad <body>", + "bad </body>" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "div": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "attrs": [ + { + "name": "a", + "value": "b" + } + ], + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "div" + }, + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body a=\"b\"><template><div></div><div></div></template></body></html>", + "noQuirksBodyHtml": "<template><div></div><div></div></template>" + } + }, + { + "data": "<html a=b><template><div><html b=c><span></template>", + "errors": [ + "no doctype", + "bad <html>", + "missing end tags in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "div": true, + "span": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "attrs": [ + { + "name": "a", + "value": "b" + } + ], + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "span" + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html a=\"b\"><head><template><div><span></span></div></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><div><span></span></div></template>" + } + }, + { + "data": "<html a=b><template><col></col><html b=c><col></col></template>", + "errors": [ + "no doctype", + "bad /col", + "bad html", + "bad /col" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "col": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "attrs": [ + { + "name": "a", + "value": "b" + } + ], + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "col" + }, + { + "tag": "col" + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html a=\"b\"><head><template><col><col></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><col><col></template>" + } + }, + { + "data": "<html a=b><template><frame></frame><html b=c><frame></frame></template>", + "errors": [ + "no doctype", + "bad frame", + "bad /frame", + "bad html", + "bad frame", + "bad /frame" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "attrs": [ + { + "name": "a", + "value": "b" + } + ], + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html a=\"b\"><head><template></template></head><body></body></html>", + "noQuirksBodyHtml": "<template></template>" + } + }, + { + "data": "<body><template><tr></tr><template></template><td></td></template>", + "errors": [ + "no doctype", + "unexpected <td>" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "tr": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "tr" + }, + { + "tag": "template", + "children": [ + { + "content": true + } + ] + }, + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><tr></tr><template></template><tr><td></td></tr></template></body></html>", + "noQuirksBodyHtml": "<template><tr></tr><template></template><tr><td></td></tr></template>" + } + }, + { + "data": "<body><template><thead></thead><template><tr></tr></template><tr></tr><tfoot></tfoot></template>", + "errors": [ + "no doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "thead": true, + "tr": true, + "tbody": true, + "tfoot": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "thead" + }, + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "tr" + } + ] + } + ] + }, + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + }, + { + "tag": "tfoot" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><thead></thead><template><tr></tr></template><tbody><tr></tr></tbody><tfoot></tfoot></template></body></html>", + "noQuirksBodyHtml": "<template><thead></thead><template><tr></tr></template><tbody><tr></tr></tbody><tfoot></tfoot></template>" + } + }, + { + "data": "<body><template><template><b><template></template></template>text</template>", + "errors": [ + "no doctype", + "missing </b>" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "b": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + } + ] + } + ] + }, + { + "text": "text" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><template><b><template></template></b></template>text</template></body></html>", + "noQuirksBodyHtml": "<template><template><b><template></template></b></template>text</template>" + } + }, + { + "data": "<body><template><col><colgroup>", + "errors": [ + "no doctype", + "bad colgroup", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "col": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "col" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><col></template></body></html>", + "noQuirksBodyHtml": "<template><col></template>" + } + }, + { + "data": "<body><template><col></colgroup>", + "errors": [ + "no doctype", + "bogus /colgroup", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "col": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "col" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><col></template></body></html>", + "noQuirksBodyHtml": "<template><col></template>" + } + }, + { + "data": "<body><template><col><colgroup></template></body>", + "errors": [ + "no doctype", + "bad colgroup" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "col": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "col" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><col></template></body></html>", + "noQuirksBodyHtml": "<template><col></template>" + } + }, + { + "data": "<body><template><col><div>", + "errors": [ + " * (1,7) missing DOCTYPE", + " * (1,27) unexpected token", + " * (1,27) unexpected end of file in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "col": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "col" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><col></template></body></html>", + "noQuirksBodyHtml": "<template><col></template>" + } + }, + { + "data": "<body><template><col></div>", + "errors": [ + "no doctype", + "bad /div", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "col": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "col" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><col></template></body></html>", + "noQuirksBodyHtml": "<template><col></template>" + } + }, + { + "data": "<body><template><col>Hello", + "errors": [ + "no doctype", + "unexpected text", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "col": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "col" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><col></template></body></html>", + "noQuirksBodyHtml": "<template><col></template>" + } + }, + { + "data": "<body><template><i><menu>Foo</i>", + "errors": [ + "no doctype", + "mising /menu", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "i": true, + "menu": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "i" + }, + { + "tag": "menu", + "children": [ + { + "tag": "i", + "children": [ + { + "text": "Foo" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><i></i><menu><i>Foo</i></menu></template></body></html>", + "noQuirksBodyHtml": "<template><i></i><menu><i>Foo</i></menu></template>" + } + }, + { + "data": "<body><template></div><div>Foo</div><template></template><tr></tr>", + "errors": [ + "no doctype", + "bogus /div", + "bogus tr", + "bogus /tr", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true, + "div": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "div", + "children": [ + { + "text": "Foo" + } + ] + }, + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template><div>Foo</div><template></template></template></body></html>", + "noQuirksBodyHtml": "<template><div>Foo</div><template></template></template>" + } + }, + { + "data": "<body><div><template></div><tr><td>Foo</td></tr></template>", + "errors": [ + " * (1,7) missing DOCTYPE", + " * (1,28) unexpected token in template", + " * (1,60) unexpected end of file" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "template": true, + "tr": true, + "td": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "text": "Foo" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div><template><tr><td>Foo</td></tr></template></div></body></html>", + "noQuirksBodyHtml": "<div><template><tr><td>Foo</td></tr></template></div>" + } + }, + { + "data": "<template></figcaption><sub><table></table>", + "errors": [ + "no doctype", + "bad /figcaption", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "sub": true, + "table": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "sub", + "children": [ + { + "tag": "table" + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><sub><table></table></sub></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><sub><table></table></sub></template>" + } + }, + { + "data": "<template><template>", + "errors": [ + "no doctype", + "eof in template", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><template></template></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><template></template></template>" + } + }, + { + "data": "<template><div>", + "errors": [ + "no doctype", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "div": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><div></div></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><div></div></template>" + } + }, + { + "data": "<template><template><div>", + "errors": [ + "no doctype", + "eof in template", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "div": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><template><div></div></template></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><template><div></div></template></template>" + } + }, + { + "data": "<template><template><table>", + "errors": [ + "no doctype", + "eof in template", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "table": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "table" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><template><table></table></template></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><template><table></table></template></template>" + } + }, + { + "data": "<template><template><tbody>", + "errors": [ + "no doctype", + "eof in template", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "tbody": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "tbody" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><template><tbody></tbody></template></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><template><tbody></tbody></template></template>" + } + }, + { + "data": "<template><template><tr>", + "errors": [ + "no doctype", + "eof in template", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "tr": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><template><tr></tr></template></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><template><tr></tr></template></template>" + } + }, + { + "data": "<template><template><td>", + "errors": [ + "no doctype", + "eof in template", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "td": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><template><td></td></template></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><template><td></td></template></template>" + } + }, + { + "data": "<template><template><caption>", + "errors": [ + "no doctype", + "eof in template", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "caption": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "caption" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><template><caption></caption></template></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><template><caption></caption></template></template>" + } + }, + { + "data": "<template><template><colgroup>", + "errors": [ + "no doctype", + "eof in template", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "colgroup": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "colgroup" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><template><colgroup></colgroup></template></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><template><colgroup></colgroup></template></template>" + } + }, + { + "data": "<template><template><col>", + "errors": [ + "no doctype", + "eof in template", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "col": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "col" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><template><col></template></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><template><col></template></template>" + } + }, + { + "data": "<template><template><tbody><select>", + "errors": [ + " * (1,11) missing DOCTYPE", + " * (1,36) unexpected token in table - foster parenting", + " * (1,36) unexpected end of file in template", + " * (1,36) unexpected end of file in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "tbody": true, + "select": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "tbody" + }, + { + "tag": "select" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><template><tbody></tbody><select></select></template></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><template><tbody></tbody><select></select></template></template>" + } + }, + { + "data": "<template><template><table>Foo", + "errors": [ + "no doctype", + "foster-parenting text F", + "foster-parenting text o", + "foster-parenting text o", + "eof", + "eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "table": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "text": "Foo" + }, + { + "tag": "table" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><template>Foo<table></table></template></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><template>Foo<table></table></template></template>" + } + }, + { + "data": "<template><template><frame>", + "errors": [ + "no doctype", + "bad tag", + "eof", + "eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><template></template></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><template></template></template>" + } + }, + { + "data": "<template><template><script>var i", + "errors": [ + "no doctype", + "eof in script", + "eof in template", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "script": true, + "body": true + }, + "template": true, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "script", + "children": [ + { + "text": "var i", + "no_escape": true + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><template><script>var i</script></template></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><template><script>var i</script></template></template>" + } + }, + { + "data": "<template><template><style>var i", + "errors": [ + "no doctype", + "eof in style", + "eof in template", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "style": true, + "body": true + }, + "template": true, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "style", + "children": [ + { + "text": "var i", + "no_escape": true + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><template><style>var i</style></template></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><template><style>var i</style></template></template>" + } + }, + { + "data": "<template><table></template><body><span>Foo", + "errors": [ + "no doctype", + "missing /table", + "bad eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "table": true, + "body": true, + "span": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "table" + } + ] + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "tag": "span", + "children": [ + { + "text": "Foo" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head><template><table></table></template></head><body><span>Foo</span></body></html>", + "noQuirksBodyHtml": "<template><table></table></template><span>Foo</span>" + } + }, + { + "data": "<template><td></template><body><span>Foo", + "errors": [ + "no doctype", + "bad eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "td": true, + "body": true, + "span": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "tag": "span", + "children": [ + { + "text": "Foo" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head><template><td></td></template></head><body><span>Foo</span></body></html>", + "noQuirksBodyHtml": "<template><td></td></template><span>Foo</span>" + } + }, + { + "data": "<template><object></template><body><span>Foo", + "errors": [ + "no doctype", + "missing /object", + "bad eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "object": true, + "body": true, + "span": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "object" + } + ] + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "tag": "span", + "children": [ + { + "text": "Foo" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head><template><object></object></template></head><body><span>Foo</span></body></html>", + "noQuirksBodyHtml": "<template><object></object></template><span>Foo</span>" + } + }, + { + "data": "<template><svg><template>", + "errors": [ + "no doctype", + "eof in template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "svg svg": true, + "svg template": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "template", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><svg><template></template></svg></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><svg><template></template></svg></template>" + } + }, + { + "data": "<template><svg><foo><template><foreignObject><div></template><div>", + "errors": [ + "no doctype", + "ugly template closure", + "bad eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "svg svg": true, + "svg foo": true, + "svg template": true, + "svg foreignObject": true, + "div": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foo", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "template", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ], + "html": "<html><head><template><svg><foo><template><foreignObject><div></div></foreignObject></template></foo></svg></template></head><body><div></div></body></html>", + "noQuirksBodyHtml": "<template><svg><foo><template><foreignObject><div></div></foreignObject></template></foo></svg></template><div></div>" + } + }, + { + "data": "<dummy><template><span></dummy>", + "errors": [ + "no doctype", + "bad end tag </dummy>", + "eof in template", + "eof in dummy" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "dummy": true, + "template": true, + "span": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "dummy", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "span" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><dummy><template><span></span></template></dummy></body></html>", + "noQuirksBodyHtml": "<dummy><template><span></span></template></dummy>" + } + }, + { + "data": "<body><table><tr><td><select><template>Foo</template><caption>A</table>", + "errors": [ + "no doctype", + "(1,62): unexpected-caption-in-select-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "select": true, + "template": true, + "caption": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "text": "Foo" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "caption", + "children": [ + { + "text": "A" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><td><select><template>Foo</template></select></td></tr></tbody><caption>A</caption></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><select><template>Foo</template></select></td></tr></tbody><caption>A</caption></table>" + } + }, + { + "data": "<body></body><template>", + "errors": [ + "no doctype", + "(1,23): template-after-body", + "(1,24): eof-in-template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "template": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><template></template></body></html>", + "noQuirksBodyHtml": "<template></template>" + } + }, + { + "data": "<head></head><template>", + "errors": [ + "no doctype", + "(1,23): template-after-head", + "(1,24): eof-in-template" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template></template></head><body></body></html>", + "noQuirksBodyHtml": "<template></template>" + } + }, + { + "data": "<head></head><template>Foo</template>", + "errors": [ + "no doctype", + "(1,23): template-after-head" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "text": "Foo" + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template>Foo</template></head><body></body></html>", + "noQuirksBodyHtml": "<template>Foo</template>" + } + }, + { + "data": "<!DOCTYPE HTML><dummy><table><template><table><template><table><script>", + "errors": [ + "eof script", + "eof template", + "eof template", + "eof table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "dummy": true, + "table": true, + "template": true, + "script": true + }, + "doctype": true, + "template": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "dummy", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "script" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><dummy><table><template><table><template><table><script></script></table></template></table></template></table></dummy></body></html>", + "noQuirksBodyHtml": "<dummy><table><template><table><template><table><script></script></table></template></table></template></table></dummy>" + } + }, + { + "data": "<template><a><table><a>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "template": true, + "a": true, + "table": true, + "body": true + }, + "template": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "template", + "children": [ + { + "content": true, + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "a" + }, + { + "tag": "table" + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><template><a><a></a><table></table></a></template></head><body></body></html>", + "noQuirksBodyHtml": "<template><a><a></a><table></table></a></template>" + } + } + ], + "tests1.dat": [ + { + "data": "Test", + "errors": [ + "(1,0): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Test" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>Test</body></html>", + "noQuirksBodyHtml": "Test" + } + }, + { + "data": "<p>One<p>Two", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "text": "One" + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "Two" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p>One</p><p>Two</p></body></html>", + "noQuirksBodyHtml": "<p>One</p><p>Two</p>" + } + }, + { + "data": "Line1<br>Line2<br>Line3<br>Line4", + "errors": [ + "(1,0): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "br": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Line1" + }, + { + "tag": "br" + }, + { + "text": "Line2" + }, + { + "tag": "br" + }, + { + "text": "Line3" + }, + { + "tag": "br" + }, + { + "text": "Line4" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>Line1<br>Line2<br>Line3<br>Line4</body></html>", + "noQuirksBodyHtml": "Line1<br>Line2<br>Line3<br>Line4" + } + }, + { + "data": "<html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<head>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<body>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<html><head>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<html><head></head>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<html><head></head><body>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<html><head></head><body></body>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<html><head><body></body></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<html><head></body></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<html><head><body></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<html><body></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<body></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<head></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "</head>", + "errors": [ + "(1,7): expected-doctype-but-got-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "</body>", + "errors": [ + "(1,7): expected-doctype-but-got-end-tag element." + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "</html>", + "errors": [ + "(1,7): expected-doctype-but-got-end-tag element." + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<b><table><td><i></table>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,14): unexpected-cell-in-table-body", + "(1,25): unexpected-cell-end-tag", + "(1,25): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "i": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "i" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b><table><tbody><tr><td><i></i></td></tr></tbody></table></b></body></html>", + "noQuirksBodyHtml": "<b><table><tbody><tr><td><i></i></td></tr></tbody></table></b>" + } + }, + { + "data": "<b><table><td></b><i></table>X", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,14): unexpected-cell-in-table-body", + "(1,18): unexpected-end-tag", + "(1,29): unexpected-cell-end-tag", + "(1,30): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "i": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "i" + } + ] + } + ] + } + ] + } + ] + }, + { + "text": "X" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b><table><tbody><tr><td><i></i></td></tr></tbody></table>X</b></body></html>", + "noQuirksBodyHtml": "<b><table><tbody><tr><td><i></i></td></tr></tbody></table>X</b>" + } + }, + { + "data": "<h1>Hello<h2>World", + "errors": [ + "(1,4): expected-doctype-but-got-start-tag", + "(1,13): unexpected-start-tag", + "(1,18): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "h1": true, + "h2": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "h1", + "children": [ + { + "text": "Hello" + } + ] + }, + { + "tag": "h2", + "children": [ + { + "text": "World" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><h1>Hello</h1><h2>World</h2></body></html>", + "noQuirksBodyHtml": "<h1>Hello</h1><h2>World</h2>" + } + }, + { + "data": "<a><p>X<a>Y</a>Z</p></a>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,10): unexpected-start-tag-implies-end-tag", + "(1,10): adoption-agency-1.3", + "(1,24): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a" + }, + { + "tag": "p", + "children": [ + { + "tag": "a", + "children": [ + { + "text": "X" + } + ] + }, + { + "tag": "a", + "children": [ + { + "text": "Y" + } + ] + }, + { + "text": "Z" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a></a><p><a>X</a><a>Y</a>Z</p></body></html>", + "noQuirksBodyHtml": "<a></a><p><a>X</a><a>Y</a>Z</p>" + } + }, + { + "data": "<b><button>foo</b>bar", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,18): adoption-agency-1.3", + "(1,21): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "button": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b" + }, + { + "tag": "button", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "foo" + } + ] + }, + { + "text": "bar" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b></b><button><b>foo</b>bar</button></body></html>", + "noQuirksBodyHtml": "<b></b><button><b>foo</b>bar</button>" + } + }, + { + "data": "<!DOCTYPE html><span><button>foo</span>bar", + "errors": [ + "(1,39): unexpected-end-tag", + "(1,42): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "span": true, + "button": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "span", + "children": [ + { + "tag": "button", + "children": [ + { + "text": "foobar" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><span><button>foobar</button></span></body></html>", + "noQuirksBodyHtml": "<span><button>foobar</button></span>" + } + }, + { + "data": "<p><b><div><marquee></p></b></div>X", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,11): unexpected-end-tag", + "(1,24): unexpected-end-tag", + "(1,28): unexpected-end-tag", + "(1,34): end-tag-too-early", + "(1,35): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "b": true, + "div": true, + "marquee": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "b" + } + ] + }, + { + "tag": "div", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "marquee", + "children": [ + { + "tag": "p" + }, + { + "text": "X" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p><b></b></p><div><b><marquee><p></p>X</marquee></b></div></body></html>", + "noQuirksBodyHtml": "<p><b></b></p><div><b><marquee><p></p>X</marquee></b></div>" + } + }, + { + "data": "<script><div></script></div><title><p></title><p><p>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,28): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "title": true, + "body": true, + "p": true + }, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<div>", + "no_escape": true + } + ] + }, + { + "tag": "title", + "children": [ + { + "text": "<p>", + "escaped": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "tag": "p" + }, + { + "tag": "p" + } + ] + } + ] + } + ], + "html": "<html><head><script><div></script><title>&lt;p&gt;</title></head><body><p></p><p></p></body></html>", + "noQuirksBodyHtml": "<script><div></script><title>&lt;p&gt;</title><p></p><p></p>" + } + }, + { + "data": "<!--><div>--<!-->", + "errors": [ + "(1,5): incorrect-comment", + "(1,10): expected-doctype-but-got-start-tag", + "(1,17): incorrect-comment", + "(1,17): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "comment": true + }, + "tree": [ + { + "comment": "" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "--" + }, + { + "comment": "" + } + ] + } + ] + } + ] + } + ], + "html": "<!----><html><head></head><body><div>--<!----></div></body></html>", + "noQuirksBodyHtml": "<!----><div>--<!----></div>" + } + }, + { + "data": "<p><hr></p>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,11): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "hr": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p" + }, + { + "tag": "hr" + }, + { + "tag": "p" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p></p><hr><p></p></body></html>", + "noQuirksBodyHtml": "<p></p><hr><p></p>" + } + }, + { + "data": "<select><b><option><select><option></b></select>X", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,11): unexpected-start-tag-in-select", + "(1,27): unexpected-select-in-select", + "(1,39): unexpected-end-tag", + "(1,48): unexpected-end-tag", + "(1,49): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "option": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "option" + } + ] + }, + { + "tag": "option", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><select><option></option></select><option>X</option></body></html>", + "noQuirksBodyHtml": "<select><option></option></select><option>X</option>" + } + }, + { + "data": "<a><table><td><a><table></table><a></tr><a></table><b>X</b>C<a>Y", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,14): unexpected-cell-in-table-body", + "(1,35): unexpected-start-tag-implies-end-tag", + "(1,40): unexpected-cell-end-tag", + "(1,43): unexpected-start-tag-implies-table-voodoo", + "(1,43): unexpected-start-tag-implies-end-tag", + "(1,43): unexpected-end-tag", + "(1,63): unexpected-start-tag-implies-end-tag", + "(1,64): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "b": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "a" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "table" + } + ] + }, + { + "tag": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "a", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "X" + } + ] + }, + { + "text": "C" + } + ] + }, + { + "tag": "a", + "children": [ + { + "text": "Y" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a><a></a><table><tbody><tr><td><a><table></table></a><a></a></td></tr></tbody></table></a><a><b>X</b>C</a><a>Y</a></body></html>", + "noQuirksBodyHtml": "<a><a></a><table><tbody><tr><td><a><table></table></a><a></a></td></tr></tbody></table></a><a><b>X</b>C</a><a>Y</a>" + } + }, + { + "data": "<a X>0<b>1<a Y>2", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,15): unexpected-start-tag-implies-end-tag", + "(1,15): adoption-agency-1.3", + "(1,16): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "b": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "attrs": [ + { + "name": "x", + "value": "" + } + ], + "children": [ + { + "text": "0" + }, + { + "tag": "b", + "children": [ + { + "text": "1" + } + ] + } + ] + }, + { + "tag": "b", + "children": [ + { + "tag": "a", + "attrs": [ + { + "name": "y", + "value": "" + } + ], + "children": [ + { + "text": "2" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a x=\"\">0<b>1</b></a><b><a y=\"\">2</a></b></body></html>", + "noQuirksBodyHtml": "<a x=\"\">0<b>1</b></a><b><a y=\"\">2</a></b>" + } + }, + { + "data": "<!-----><font><div>hello<table>excite!<b>me!<th><i>please!</tr><!--X-->", + "errors": [ + "(1,7): unexpected-dash-after-double-dash-in-comment", + "(1,14): expected-doctype-but-got-start-tag", + "(1,41): unexpected-start-tag-implies-table-voodoo", + "(1,48): foster-parenting-character-in-table", + "(1,48): foster-parenting-character-in-table", + "(1,48): foster-parenting-character-in-table", + "(1,48): foster-parenting-character-in-table", + "(1,48): foster-parenting-character-in-table", + "(1,48): foster-parenting-character-in-table", + "(1,48): foster-parenting-character-in-table", + "(1,48): foster-parenting-character-in-table", + "(1,48): foster-parenting-character-in-table", + "(1,48): foster-parenting-character-in-table", + "(1,48): unexpected-cell-in-table-body", + "(1,63): unexpected-cell-end-tag", + "(1,71): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "font": true, + "div": true, + "b": true, + "table": true, + "tbody": true, + "tr": true, + "th": true, + "i": true + }, + "comment": true + }, + "tree": [ + { + "comment": "-" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "font", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "helloexcite!" + }, + { + "tag": "b", + "children": [ + { + "text": "me!" + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "th", + "children": [ + { + "tag": "i", + "children": [ + { + "text": "please!" + } + ] + } + ] + } + ] + }, + { + "comment": "X" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!-----><html><head></head><body><font><div>helloexcite!<b>me!</b><table><tbody><tr><th><i>please!</i></th></tr><!--X--></tbody></table></div></font></body></html>", + "noQuirksBodyHtml": "<!-----><font><div>helloexcite!<b>me!</b><table><tbody><tr><th><i>please!</i></th></tr><!--X--></tbody></table></div></font>" + } + }, + { + "data": "<!DOCTYPE html><li>hello<li>world<ul>how<li>do</ul>you</body><!--do-->", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "li": true, + "ul": true + }, + "doctype": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "li", + "children": [ + { + "text": "hello" + } + ] + }, + { + "tag": "li", + "children": [ + { + "text": "world" + }, + { + "tag": "ul", + "children": [ + { + "text": "how" + }, + { + "tag": "li", + "children": [ + { + "text": "do" + } + ] + } + ] + }, + { + "text": "you" + } + ] + } + ] + }, + { + "comment": "do" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><li>hello</li><li>world<ul>how<li>do</li></ul>you</li></body><!--do--></html>", + "noQuirksBodyHtml": "<li>hello</li><li>world<ul>how<li>do</li></ul>you<!--do--></li>" + } + }, + { + "data": "<!DOCTYPE html>A<option>B<optgroup>C<select>D</option>E", + "errors": [ + "(1,54): unexpected-end-tag-in-select", + "(1,55): eof-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "option": true, + "optgroup": true, + "select": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "A" + }, + { + "tag": "option", + "children": [ + { + "text": "B" + } + ] + }, + { + "tag": "optgroup", + "children": [ + { + "text": "C" + }, + { + "tag": "select", + "children": [ + { + "text": "DE" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>A<option>B</option><optgroup>C<select>DE</select></optgroup></body></html>", + "noQuirksBodyHtml": "A<option>B</option><optgroup>C<select>DE</select></optgroup>" + } + }, + { + "data": "<", + "errors": [ + "(1,1): expected-tag-name", + "(1,1): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "<", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body>&lt;</body></html>", + "noQuirksBodyHtml": "&lt;" + } + }, + { + "data": "<#", + "errors": [ + "(1,1): expected-tag-name", + "(1,1): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "<#", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body>&lt;#</body></html>", + "noQuirksBodyHtml": "&lt;#" + } + }, + { + "data": "</", + "errors": [ + "(1,2): expected-closing-tag-but-got-eof", + "(1,2): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "</", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body>&lt;/</body></html>", + "noQuirksBodyHtml": "&lt;/" + } + }, + { + "data": "</#", + "errors": [ + "(1,2): expected-closing-tag-but-got-char", + "(1,3): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "comment": "#" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!--#--><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!--#-->" + } + }, + { + "data": "<?", + "errors": [ + "(1,1): expected-tag-name-but-got-question-mark", + "(1,2): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "comment": "?" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!--?--><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!--?-->" + } + }, + { + "data": "<?#", + "errors": [ + "(1,1): expected-tag-name-but-got-question-mark", + "(1,3): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "comment": "?#" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!--?#--><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!--?#-->" + } + }, + { + "data": "<!", + "errors": [ + "(1,2): expected-dashes-or-doctype", + "(1,2): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "comment": "" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!----><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!---->" + } + }, + { + "data": "<!#", + "errors": [ + "(1,2): expected-dashes-or-doctype", + "(1,3): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "comment": "#" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!--#--><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!--#-->" + } + }, + { + "data": "<?COMMENT?>", + "errors": [ + "(1,1): expected-tag-name-but-got-question-mark", + "(1,11): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "comment": "?COMMENT?" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!--?COMMENT?--><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!--?COMMENT?-->" + } + }, + { + "data": "<!COMMENT>", + "errors": [ + "(1,2): expected-dashes-or-doctype", + "(1,10): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "comment": "COMMENT" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!--COMMENT--><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!--COMMENT-->" + } + }, + { + "data": "</ COMMENT >", + "errors": [ + "(1,2): expected-closing-tag-but-got-char", + "(1,12): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "comment": " COMMENT " + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!-- COMMENT --><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!-- COMMENT -->" + } + }, + { + "data": "<?COM--MENT?>", + "errors": [ + "(1,1): expected-tag-name-but-got-question-mark", + "(1,13): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "comment": "?COM--MENT?" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!--?COM--MENT?--><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!--?COM--MENT?-->" + } + }, + { + "data": "<!COM--MENT>", + "errors": [ + "(1,2): expected-dashes-or-doctype", + "(1,12): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "comment": "COM--MENT" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!--COM--MENT--><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!--COM--MENT-->" + } + }, + { + "data": "</ COM--MENT >", + "errors": [ + "(1,2): expected-closing-tag-but-got-char", + "(1,14): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "comment": " COM--MENT " + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!-- COM--MENT --><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!-- COM--MENT -->" + } + }, + { + "data": "<!DOCTYPE html><style> EOF", + "errors": [ + "(1,26): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": " EOF", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><style> EOF</style></head><body></body></html>", + "noQuirksBodyHtml": "<style> EOF</style>" + } + }, + { + "data": "<!DOCTYPE html><script> <!-- </script> --> </script> EOF", + "errors": [ + "(1,52): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": " <!-- ", + "no_escape": true + } + ] + }, + { + "text": " " + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "--> EOF", + "escaped": true + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script> <!-- </script> </head><body>--&gt; EOF</body></html>", + "noQuirksBodyHtml": "<script> <!-- </script> --&gt; EOF" + } + }, + { + "data": "<b><p></b>TEST", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,10): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b" + }, + { + "tag": "p", + "children": [ + { + "tag": "b" + }, + { + "text": "TEST" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b></b><p><b></b>TEST</p></body></html>", + "noQuirksBodyHtml": "<b></b><p><b></b>TEST</p>" + } + }, + { + "data": "<p id=a><b><p id=b></b>TEST", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,19): unexpected-end-tag", + "(1,23): adoption-agency-1.2" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "b": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "attrs": [ + { + "name": "id", + "value": "a" + } + ], + "children": [ + { + "tag": "b" + } + ] + }, + { + "tag": "p", + "attrs": [ + { + "name": "id", + "value": "b" + } + ], + "children": [ + { + "text": "TEST" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p id=\"a\"><b></b></p><p id=\"b\">TEST</p></body></html>", + "noQuirksBodyHtml": "<p id=\"a\"><b></b></p><p id=\"b\">TEST</p>" + } + }, + { + "data": "<b id=a><p><b id=b></p></b>TEST", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,23): unexpected-end-tag", + "(1,27): adoption-agency-1.2", + "(1,31): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "attrs": [ + { + "name": "id", + "value": "a" + } + ], + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "b", + "attrs": [ + { + "name": "id", + "value": "b" + } + ] + } + ] + }, + { + "text": "TEST" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b id=\"a\"><p><b id=\"b\"></b></p>TEST</b></body></html>", + "noQuirksBodyHtml": "<b id=\"a\"><p><b id=\"b\"></b></p>TEST</b>" + } + }, + { + "data": "<!DOCTYPE html><title>U-test</title><body><div><p>Test<u></p></div></body>", + "errors": [ + "(1,61): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "body": true, + "div": true, + "p": true, + "u": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "U-test" + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "p", + "children": [ + { + "text": "Test" + }, + { + "tag": "u" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><title>U-test</title></head><body><div><p>Test<u></u></p></div></body></html>", + "noQuirksBodyHtml": "<title>U-test</title><div><p>Test<u></u></p></div>" + } + }, + { + "data": "<!DOCTYPE html><font><table></font></table></font>", + "errors": [ + "(1,35): unexpected-end-tag-implies-table-voodoo", + "(1,35): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "font": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "font", + "children": [ + { + "tag": "table" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><font><table></table></font></body></html>", + "noQuirksBodyHtml": "<font><table></table></font>" + } + }, + { + "data": "<font><p>hello<b>cruel</font>world", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,29): adoption-agency-1.3", + "(1,29): adoption-agency-1.3", + "(1,34): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "font": true, + "p": true, + "b": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "font" + }, + { + "tag": "p", + "children": [ + { + "tag": "font", + "children": [ + { + "text": "hello" + }, + { + "tag": "b", + "children": [ + { + "text": "cruel" + } + ] + } + ] + }, + { + "tag": "b", + "children": [ + { + "text": "world" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><font></font><p><font>hello<b>cruel</b></font><b>world</b></p></body></html>", + "noQuirksBodyHtml": "<font></font><p><font>hello<b>cruel</b></font><b>world</b></p>" + } + }, + { + "data": "<b>Test</i>Test", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,11): unexpected-end-tag", + "(1,15): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "TestTest" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b>TestTest</b></body></html>", + "noQuirksBodyHtml": "<b>TestTest</b>" + } + }, + { + "data": "<b>A<cite>B<div>C", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,17): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "cite": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "A" + }, + { + "tag": "cite", + "children": [ + { + "text": "B" + }, + { + "tag": "div", + "children": [ + { + "text": "C" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b>A<cite>B<div>C</div></cite></b></body></html>", + "noQuirksBodyHtml": "<b>A<cite>B<div>C</div></cite></b>" + } + }, + { + "data": "<b>A<cite>B<div>C</cite>D", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,24): unexpected-end-tag", + "(1,25): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "cite": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "A" + }, + { + "tag": "cite", + "children": [ + { + "text": "B" + }, + { + "tag": "div", + "children": [ + { + "text": "CD" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b>A<cite>B<div>CD</div></cite></b></body></html>", + "noQuirksBodyHtml": "<b>A<cite>B<div>CD</div></cite></b>" + } + }, + { + "data": "<b>A<cite>B<div>C</b>D", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,21): adoption-agency-1.3", + "(1,22): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "cite": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "A" + }, + { + "tag": "cite", + "children": [ + { + "text": "B" + } + ] + } + ] + }, + { + "tag": "div", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "C" + } + ] + }, + { + "text": "D" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b>A<cite>B</cite></b><div><b>C</b>D</div></body></html>", + "noQuirksBodyHtml": "<b>A<cite>B</cite></b><div><b>C</b>D</div>" + } + }, + { + "data": "", + "errors": [ + "(1,0): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<DIV>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,5): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div></div></body></html>", + "noQuirksBodyHtml": "<div></div>" + } + }, + { + "data": "<DIV> abc", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,9): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": " abc" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div> abc</div></body></html>", + "noQuirksBodyHtml": "<div> abc</div>" + } + }, + { + "data": "<DIV> abc <B>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,13): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "b": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": " abc " + }, + { + "tag": "b" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div> abc <b></b></div></body></html>", + "noQuirksBodyHtml": "<div> abc <b></b></div>" + } + }, + { + "data": "<DIV> abc <B> def", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,17): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "b": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": " abc " + }, + { + "tag": "b", + "children": [ + { + "text": " def" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div> abc <b> def</b></div></body></html>", + "noQuirksBodyHtml": "<div> abc <b> def</b></div>" + } + }, + { + "data": "<DIV> abc <B> def <I>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,21): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "b": true, + "i": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": " abc " + }, + { + "tag": "b", + "children": [ + { + "text": " def " + }, + { + "tag": "i" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div> abc <b> def <i></i></b></div></body></html>", + "noQuirksBodyHtml": "<div> abc <b> def <i></i></b></div>" + } + }, + { + "data": "<DIV> abc <B> def <I> ghi", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,25): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "b": true, + "i": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": " abc " + }, + { + "tag": "b", + "children": [ + { + "text": " def " + }, + { + "tag": "i", + "children": [ + { + "text": " ghi" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div> abc <b> def <i> ghi</i></b></div></body></html>", + "noQuirksBodyHtml": "<div> abc <b> def <i> ghi</i></b></div>" + } + }, + { + "data": "<DIV> abc <B> def <I> ghi <P>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,29): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "b": true, + "i": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": " abc " + }, + { + "tag": "b", + "children": [ + { + "text": " def " + }, + { + "tag": "i", + "children": [ + { + "text": " ghi " + }, + { + "tag": "p" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div> abc <b> def <i> ghi <p></p></i></b></div></body></html>", + "noQuirksBodyHtml": "<div> abc <b> def <i> ghi <p></p></i></b></div>" + } + }, + { + "data": "<DIV> abc <B> def <I> ghi <P> jkl", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,33): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "b": true, + "i": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": " abc " + }, + { + "tag": "b", + "children": [ + { + "text": " def " + }, + { + "tag": "i", + "children": [ + { + "text": " ghi " + }, + { + "tag": "p", + "children": [ + { + "text": " jkl" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div> abc <b> def <i> ghi <p> jkl</p></i></b></div></body></html>", + "noQuirksBodyHtml": "<div> abc <b> def <i> ghi <p> jkl</p></i></b></div>" + } + }, + { + "data": "<DIV> abc <B> def <I> ghi <P> jkl </B>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,38): adoption-agency-1.3", + "(1,38): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "b": true, + "i": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": " abc " + }, + { + "tag": "b", + "children": [ + { + "text": " def " + }, + { + "tag": "i", + "children": [ + { + "text": " ghi " + } + ] + } + ] + }, + { + "tag": "i", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "b", + "children": [ + { + "text": " jkl " + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div> abc <b> def <i> ghi </i></b><i><p><b> jkl </b></p></i></div></body></html>", + "noQuirksBodyHtml": "<div> abc <b> def <i> ghi </i></b><i><p><b> jkl </b></p></i></div>" + } + }, + { + "data": "<DIV> abc <B> def <I> ghi <P> jkl </B> mno", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,38): adoption-agency-1.3", + "(1,42): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "b": true, + "i": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": " abc " + }, + { + "tag": "b", + "children": [ + { + "text": " def " + }, + { + "tag": "i", + "children": [ + { + "text": " ghi " + } + ] + } + ] + }, + { + "tag": "i", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "b", + "children": [ + { + "text": " jkl " + } + ] + }, + { + "text": " mno" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div> abc <b> def <i> ghi </i></b><i><p><b> jkl </b> mno</p></i></div></body></html>", + "noQuirksBodyHtml": "<div> abc <b> def <i> ghi </i></b><i><p><b> jkl </b> mno</p></i></div>" + } + }, + { + "data": "<DIV> abc <B> def <I> ghi <P> jkl </B> mno </I>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,38): adoption-agency-1.3", + "(1,47): adoption-agency-1.3", + "(1,47): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "b": true, + "i": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": " abc " + }, + { + "tag": "b", + "children": [ + { + "text": " def " + }, + { + "tag": "i", + "children": [ + { + "text": " ghi " + } + ] + } + ] + }, + { + "tag": "i" + }, + { + "tag": "p", + "children": [ + { + "tag": "i", + "children": [ + { + "tag": "b", + "children": [ + { + "text": " jkl " + } + ] + }, + { + "text": " mno " + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div> abc <b> def <i> ghi </i></b><i></i><p><i><b> jkl </b> mno </i></p></div></body></html>", + "noQuirksBodyHtml": "<div> abc <b> def <i> ghi </i></b><i></i><p><i><b> jkl </b> mno </i></p></div>" + } + }, + { + "data": "<DIV> abc <B> def <I> ghi <P> jkl </B> mno </I> pqr", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,38): adoption-agency-1.3", + "(1,47): adoption-agency-1.3", + "(1,51): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "b": true, + "i": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": " abc " + }, + { + "tag": "b", + "children": [ + { + "text": " def " + }, + { + "tag": "i", + "children": [ + { + "text": " ghi " + } + ] + } + ] + }, + { + "tag": "i" + }, + { + "tag": "p", + "children": [ + { + "tag": "i", + "children": [ + { + "tag": "b", + "children": [ + { + "text": " jkl " + } + ] + }, + { + "text": " mno " + } + ] + }, + { + "text": " pqr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div> abc <b> def <i> ghi </i></b><i></i><p><i><b> jkl </b> mno </i> pqr</p></div></body></html>", + "noQuirksBodyHtml": "<div> abc <b> def <i> ghi </i></b><i></i><p><i><b> jkl </b> mno </i> pqr</p></div>" + } + }, + { + "data": "<DIV> abc <B> def <I> ghi <P> jkl </B> mno </I> pqr </P>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,38): adoption-agency-1.3", + "(1,47): adoption-agency-1.3", + "(1,56): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "b": true, + "i": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": " abc " + }, + { + "tag": "b", + "children": [ + { + "text": " def " + }, + { + "tag": "i", + "children": [ + { + "text": " ghi " + } + ] + } + ] + }, + { + "tag": "i" + }, + { + "tag": "p", + "children": [ + { + "tag": "i", + "children": [ + { + "tag": "b", + "children": [ + { + "text": " jkl " + } + ] + }, + { + "text": " mno " + } + ] + }, + { + "text": " pqr " + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div> abc <b> def <i> ghi </i></b><i></i><p><i><b> jkl </b> mno </i> pqr </p></div></body></html>", + "noQuirksBodyHtml": "<div> abc <b> def <i> ghi </i></b><i></i><p><i><b> jkl </b> mno </i> pqr </p></div>" + } + }, + { + "data": "<DIV> abc <B> def <I> ghi <P> jkl </B> mno </I> pqr </P> stu", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,38): adoption-agency-1.3", + "(1,47): adoption-agency-1.3", + "(1,60): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "b": true, + "i": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": " abc " + }, + { + "tag": "b", + "children": [ + { + "text": " def " + }, + { + "tag": "i", + "children": [ + { + "text": " ghi " + } + ] + } + ] + }, + { + "tag": "i" + }, + { + "tag": "p", + "children": [ + { + "tag": "i", + "children": [ + { + "tag": "b", + "children": [ + { + "text": " jkl " + } + ] + }, + { + "text": " mno " + } + ] + }, + { + "text": " pqr " + } + ] + }, + { + "text": " stu" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div> abc <b> def <i> ghi </i></b><i></i><p><i><b> jkl </b> mno </i> pqr </p> stu</div></body></html>", + "noQuirksBodyHtml": "<div> abc <b> def <i> ghi </i></b><i></i><p><i><b> jkl </b> mno </i> pqr </p> stu</div>" + } + }, + { + "data": "<test attribute---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------->", + "errors": [ + "(1,1040): expected-doctype-but-got-start-tag", + "(1,1040): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "test": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "test", + "attrs": [ + { + "name": "attribute----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------", + "value": "" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><test attribute----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------=\"\"></test></body></html>", + "noQuirksBodyHtml": "<test attribute----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------=\"\"></test>" + } + }, + { + "data": "<a href=\"blah\">aba<table><a href=\"foo\">br<tr><td></td></tr>x</table>aoe", + "errors": [ + "(1,15): expected-doctype-but-got-start-tag", + "(1,39): unexpected-start-tag-implies-table-voodoo", + "(1,39): unexpected-start-tag-implies-end-tag", + "(1,39): unexpected-end-tag", + "(1,45): foster-parenting-character-in-table", + "(1,45): foster-parenting-character-in-table", + "(1,68): foster-parenting-character-in-table", + "(1,71): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "attrs": [ + { + "name": "href", + "value": "blah" + } + ], + "children": [ + { + "text": "aba" + }, + { + "tag": "a", + "attrs": [ + { + "name": "href", + "value": "foo" + } + ], + "children": [ + { + "text": "br" + } + ] + }, + { + "tag": "a", + "attrs": [ + { + "name": "href", + "value": "foo" + } + ], + "children": [ + { + "text": "x" + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "a", + "attrs": [ + { + "name": "href", + "value": "foo" + } + ], + "children": [ + { + "text": "aoe" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a href=\"blah\">aba<a href=\"foo\">br</a><a href=\"foo\">x</a><table><tbody><tr><td></td></tr></tbody></table></a><a href=\"foo\">aoe</a></body></html>", + "noQuirksBodyHtml": "<a href=\"blah\">aba<a href=\"foo\">br</a><a href=\"foo\">x</a><table><tbody><tr><td></td></tr></tbody></table></a><a href=\"foo\">aoe</a>" + } + }, + { + "data": "<a href=\"blah\">aba<table><tr><td><a href=\"foo\">br</td></tr>x</table>aoe", + "errors": [ + "(1,15): expected-doctype-but-got-start-tag", + "(1,54): unexpected-cell-end-tag", + "(1,68): unexpected text in table", + "(1,71): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "attrs": [ + { + "name": "href", + "value": "blah" + } + ], + "children": [ + { + "text": "abax" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "a", + "attrs": [ + { + "name": "href", + "value": "foo" + } + ], + "children": [ + { + "text": "br" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "text": "aoe" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a href=\"blah\">abax<table><tbody><tr><td><a href=\"foo\">br</a></td></tr></tbody></table>aoe</a></body></html>", + "noQuirksBodyHtml": "<a href=\"blah\">abax<table><tbody><tr><td><a href=\"foo\">br</a></td></tr></tbody></table>aoe</a>" + } + }, + { + "data": "<table><a href=\"blah\">aba<tr><td><a href=\"foo\">br</td></tr>x</table>aoe", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,22): unexpected-start-tag-implies-table-voodoo", + "(1,29): foster-parenting-character-in-table", + "(1,29): foster-parenting-character-in-table", + "(1,29): foster-parenting-character-in-table", + "(1,54): unexpected-cell-end-tag", + "(1,68): foster-parenting-character-in-table", + "(1,71): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "attrs": [ + { + "name": "href", + "value": "blah" + } + ], + "children": [ + { + "text": "aba" + } + ] + }, + { + "tag": "a", + "attrs": [ + { + "name": "href", + "value": "blah" + } + ], + "children": [ + { + "text": "x" + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "a", + "attrs": [ + { + "name": "href", + "value": "foo" + } + ], + "children": [ + { + "text": "br" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "a", + "attrs": [ + { + "name": "href", + "value": "blah" + } + ], + "children": [ + { + "text": "aoe" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a href=\"blah\">aba</a><a href=\"blah\">x</a><table><tbody><tr><td><a href=\"foo\">br</a></td></tr></tbody></table><a href=\"blah\">aoe</a></body></html>", + "noQuirksBodyHtml": "<a href=\"blah\">aba</a><a href=\"blah\">x</a><table><tbody><tr><td><a href=\"foo\">br</a></td></tr></tbody></table><a href=\"blah\">aoe</a>" + } + }, + { + "data": "<a href=a>aa<marquee>aa<a href=b>bb</marquee>aa", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(1,45): end-tag-too-early", + "(1,47): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "marquee": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "attrs": [ + { + "name": "href", + "value": "a" + } + ], + "children": [ + { + "text": "aa" + }, + { + "tag": "marquee", + "children": [ + { + "text": "aa" + }, + { + "tag": "a", + "attrs": [ + { + "name": "href", + "value": "b" + } + ], + "children": [ + { + "text": "bb" + } + ] + } + ] + }, + { + "text": "aa" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a href=\"a\">aa<marquee>aa<a href=\"b\">bb</a></marquee>aa</a></body></html>", + "noQuirksBodyHtml": "<a href=\"a\">aa<marquee>aa<a href=\"b\">bb</a></marquee>aa</a>" + } + }, + { + "data": "<wbr><strike><code></strike><code><strike></code>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,28): adoption-agency-1.3", + "(1,49): adoption-agency-1.3", + "(1,49): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "wbr": true, + "strike": true, + "code": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "wbr" + }, + { + "tag": "strike", + "children": [ + { + "tag": "code" + } + ] + }, + { + "tag": "code", + "children": [ + { + "tag": "code", + "children": [ + { + "tag": "strike" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><wbr><strike><code></code></strike><code><code><strike></strike></code></code></body></html>", + "noQuirksBodyHtml": "<wbr><strike><code></code></strike><code><code><strike></strike></code></code>" + } + }, + { + "data": "<!DOCTYPE html><spacer>foo", + "errors": [ + "(1,26): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "spacer": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "spacer", + "children": [ + { + "text": "foo" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><spacer>foo</spacer></body></html>", + "noQuirksBodyHtml": "<spacer>foo</spacer>" + } + }, + { + "data": "<title><meta></title><link><title><meta></title>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "link": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "<meta>", + "escaped": true + } + ] + }, + { + "tag": "link" + }, + { + "tag": "title", + "children": [ + { + "text": "<meta>", + "escaped": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><title>&lt;meta&gt;</title><link><title>&lt;meta&gt;</title></head><body></body></html>", + "noQuirksBodyHtml": "<title>&lt;meta&gt;</title><link><title>&lt;meta&gt;</title>" + } + }, + { + "data": "<style><!--</style><meta><script>--><link></script>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "meta": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "<!--", + "no_escape": true + } + ] + }, + { + "tag": "meta" + }, + { + "tag": "script", + "children": [ + { + "text": "--><link>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><style><!--</style><meta><script>--><link></script></head><body></body></html>", + "noQuirksBodyHtml": "<style><!--</style><meta><script>--><link></script>" + } + }, + { + "data": "<head><meta></head><link>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,25): unexpected-start-tag-out-of-my-head" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "meta": true, + "link": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "meta" + }, + { + "tag": "link" + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><meta><link></head><body></body></html>", + "noQuirksBodyHtml": "<meta><link>" + } + }, + { + "data": "<table><tr><tr><td><td><span><th><span>X</table>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,33): unexpected-cell-end-tag", + "(1,48): unexpected-cell-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "span": true, + "th": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + }, + { + "tag": "tr", + "children": [ + { + "tag": "td" + }, + { + "tag": "td", + "children": [ + { + "tag": "span" + } + ] + }, + { + "tag": "th", + "children": [ + { + "tag": "span", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr></tr><tr><td></td><td><span></span></td><th><span>X</span></th></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr></tr><tr><td></td><td><span></span></td><th><span>X</span></th></tr></tbody></table>" + } + }, + { + "data": "<body><body><base><link><meta><title><p></title><body><p></body>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,12): unexpected-start-tag", + "(1,54): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "base": true, + "link": true, + "meta": true, + "title": true, + "p": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "base" + }, + { + "tag": "link" + }, + { + "tag": "meta" + }, + { + "tag": "title", + "children": [ + { + "text": "<p>", + "escaped": true + } + ] + }, + { + "tag": "p" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><base><link><meta><title>&lt;p&gt;</title><p></p></body></html>", + "noQuirksBodyHtml": "<base><link><meta><title>&lt;p&gt;</title><p></p>" + } + }, + { + "data": "<textarea><p></textarea>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "textarea": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "textarea", + "children": [ + { + "text": "<p>", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><textarea>&lt;p&gt;</textarea></body></html>", + "noQuirksBodyHtml": "<textarea>&lt;p&gt;</textarea>" + } + }, + { + "data": "<p><image></p>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,10): unexpected-start-tag-treated-as" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "img": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "img" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p><img></p></body></html>", + "noQuirksBodyHtml": "<p><img></p>" + } + }, + { + "data": "<a><table><a></table><p><a><div><a>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,13): unexpected-start-tag-implies-table-voodoo", + "(1,13): unexpected-start-tag-implies-end-tag", + "(1,13): adoption-agency-1.3", + "(1,27): unexpected-start-tag-implies-end-tag", + "(1,27): adoption-agency-1.2", + "(1,32): unexpected-end-tag", + "(1,35): unexpected-start-tag-implies-end-tag", + "(1,35): adoption-agency-1.2", + "(1,35): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "table": true, + "p": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "a" + }, + { + "tag": "table" + } + ] + }, + { + "tag": "p", + "children": [ + { + "tag": "a" + } + ] + }, + { + "tag": "div", + "children": [ + { + "tag": "a" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a><a></a><table></table></a><p><a></a></p><div><a></a></div></body></html>", + "noQuirksBodyHtml": "<a><a></a><table></table></a><p><a></a></p><div><a></a></div>" + } + }, + { + "data": "<head></p><meta><p>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,10): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "meta": true, + "body": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "meta" + } + ] + }, + { + "tag": "body", + "children": [ + { + "tag": "p" + } + ] + } + ] + } + ], + "html": "<html><head><meta></head><body><p></p></body></html>", + "noQuirksBodyHtml": "<p></p><meta><p></p>" + } + }, + { + "data": "<head></html><meta><p>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,19): expected-eof-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "meta": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "meta" + }, + { + "tag": "p" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><meta><p></p></body></html>", + "noQuirksBodyHtml": "<meta><p></p>" + } + }, + { + "data": "<b><table><td><i></table>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,14): unexpected-cell-in-table-body", + "(1,25): unexpected-cell-end-tag", + "(1,25): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "i": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "i" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b><table><tbody><tr><td><i></i></td></tr></tbody></table></b></body></html>", + "noQuirksBodyHtml": "<b><table><tbody><tr><td><i></i></td></tr></tbody></table></b>" + } + }, + { + "data": "<b><table><td></b><i></table>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,14): unexpected-cell-in-table-body", + "(1,18): unexpected-end-tag", + "(1,29): unexpected-cell-end-tag", + "(1,29): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "i": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "i" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b><table><tbody><tr><td><i></i></td></tr></tbody></table></b></body></html>", + "noQuirksBodyHtml": "<b><table><tbody><tr><td><i></i></td></tr></tbody></table></b>" + } + }, + { + "data": "<h1><h2>", + "errors": [ + "(1,4): expected-doctype-but-got-start-tag", + "(1,8): unexpected-start-tag", + "(1,8): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "h1": true, + "h2": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "h1" + }, + { + "tag": "h2" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><h1></h1><h2></h2></body></html>", + "noQuirksBodyHtml": "<h1></h1><h2></h2>" + } + }, + { + "data": "<a><p><a></a></p></a>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,9): unexpected-start-tag-implies-end-tag", + "(1,9): adoption-agency-1.3", + "(1,21): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a" + }, + { + "tag": "p", + "children": [ + { + "tag": "a" + }, + { + "tag": "a" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a></a><p><a></a><a></a></p></body></html>", + "noQuirksBodyHtml": "<a></a><p><a></a><a></a></p>" + } + }, + { + "data": "<b><button></b></button></b>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,15): adoption-agency-1.3", + "(1,28): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "button": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b" + }, + { + "tag": "button", + "children": [ + { + "tag": "b" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b></b><button><b></b></button></body></html>", + "noQuirksBodyHtml": "<b></b><button><b></b></button>" + } + }, + { + "data": "<p><b><div><marquee></p></b></div>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,11): unexpected-end-tag", + "(1,24): unexpected-end-tag", + "(1,28): unexpected-end-tag", + "(1,34): end-tag-too-early", + "(1,34): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "b": true, + "div": true, + "marquee": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "b" + } + ] + }, + { + "tag": "div", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "marquee", + "children": [ + { + "tag": "p" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p><b></b></p><div><b><marquee><p></p></marquee></b></div></body></html>", + "noQuirksBodyHtml": "<p><b></b></p><div><b><marquee><p></p></marquee></b></div>" + } + }, + { + "data": "<script></script></div><title></title><p><p>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,23): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "title": true, + "body": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script" + }, + { + "tag": "title" + } + ] + }, + { + "tag": "body", + "children": [ + { + "tag": "p" + }, + { + "tag": "p" + } + ] + } + ] + } + ], + "html": "<html><head><script></script><title></title></head><body><p></p><p></p></body></html>", + "noQuirksBodyHtml": "<script></script><title></title><p></p><p></p>" + } + }, + { + "data": "<p><hr></p>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,11): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "hr": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p" + }, + { + "tag": "hr" + }, + { + "tag": "p" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p></p><hr><p></p></body></html>", + "noQuirksBodyHtml": "<p></p><hr><p></p>" + } + }, + { + "data": "<select><b><option><select><option></b></select>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,11): unexpected-start-tag-in-select", + "(1,27): unexpected-select-in-select", + "(1,39): unexpected-end-tag", + "(1,48): unexpected-end-tag", + "(1,48): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "option": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "option" + } + ] + }, + { + "tag": "option" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><select><option></option></select><option></option></body></html>", + "noQuirksBodyHtml": "<select><option></option></select><option></option>" + } + }, + { + "data": "<html><head><title></title><body></body></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "title" + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><title></title></head><body></body></html>", + "noQuirksBodyHtml": "<title></title>" + } + }, + { + "data": "<a><table><td><a><table></table><a></tr><a></table><a>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,14): unexpected-cell-in-table-body", + "(1,35): unexpected-start-tag-implies-end-tag", + "(1,40): unexpected-cell-end-tag", + "(1,43): unexpected-start-tag-implies-table-voodoo", + "(1,43): unexpected-start-tag-implies-end-tag", + "(1,43): unexpected-end-tag", + "(1,54): unexpected-start-tag-implies-end-tag", + "(1,54): adoption-agency-1.2", + "(1,54): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "a" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "table" + } + ] + }, + { + "tag": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "a" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a><a></a><table><tbody><tr><td><a><table></table></a><a></a></td></tr></tbody></table></a><a></a></body></html>", + "noQuirksBodyHtml": "<a><a></a><table><tbody><tr><td><a><table></table></a><a></a></td></tr></tbody></table></a><a></a>" + } + }, + { + "data": "<ul><li></li><div><li></div><li><li><div><li><address><li><b><em></b><li></ul>", + "errors": [ + "(1,4): expected-doctype-but-got-start-tag", + "(1,45): end-tag-too-early", + "(1,58): end-tag-too-early", + "(1,69): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ul": true, + "li": true, + "div": true, + "address": true, + "b": true, + "em": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ul", + "children": [ + { + "tag": "li" + }, + { + "tag": "div", + "children": [ + { + "tag": "li" + } + ] + }, + { + "tag": "li" + }, + { + "tag": "li", + "children": [ + { + "tag": "div" + } + ] + }, + { + "tag": "li", + "children": [ + { + "tag": "address" + } + ] + }, + { + "tag": "li", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "em" + } + ] + } + ] + }, + { + "tag": "li" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ul><li></li><div><li></li></div><li></li><li><div></div></li><li><address></address></li><li><b><em></em></b></li><li></li></ul></body></html>", + "noQuirksBodyHtml": "<ul><li></li><div><li></li></div><li></li><li><div></div></li><li><address></address></li><li><b><em></em></b></li><li></li></ul>" + } + }, + { + "data": "<ul><li><ul></li><li>a</li></ul></li></ul>", + "errors": [ + "(1,4): expected-doctype-but-got-start-tag", + "(1,17): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ul": true, + "li": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ul", + "children": [ + { + "tag": "li", + "children": [ + { + "tag": "ul", + "children": [ + { + "tag": "li", + "children": [ + { + "text": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ul><li><ul><li>a</li></ul></li></ul></body></html>", + "noQuirksBodyHtml": "<ul><li><ul><li>a</li></ul></li></ul>" + } + }, + { + "data": "<frameset><frame><frameset><frame></frameset><noframes></noframes></frameset>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true, + "frame": true, + "noframes": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset", + "children": [ + { + "tag": "frame" + }, + { + "tag": "frameset", + "children": [ + { + "tag": "frame" + } + ] + }, + { + "tag": "noframes" + } + ] + } + ] + } + ], + "html": "<html><head></head><frameset><frame><frameset><frame></frameset><noframes></noframes></frameset></html>", + "noQuirksBodyHtml": "<noframes></noframes>" + } + }, + { + "data": "<h1><table><td><h3></table><h3></h1>", + "errors": [ + "(1,4): expected-doctype-but-got-start-tag", + "(1,15): unexpected-cell-in-table-body", + "(1,27): unexpected-cell-end-tag", + "(1,31): unexpected-start-tag", + "(1,36): end-tag-too-early" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "h1": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "h3": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "h1", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "h3" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "h3" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><h1><table><tbody><tr><td><h3></h3></td></tr></tbody></table></h1><h3></h3></body></html>", + "noQuirksBodyHtml": "<h1><table><tbody><tr><td><h3></h3></td></tr></tbody></table></h1><h3></h3>" + } + }, + { + "data": "<table><colgroup><col><colgroup><col><col><col><colgroup><col><col><thead><tr><td></table>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "colgroup": true, + "col": true, + "thead": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "colgroup", + "children": [ + { + "tag": "col" + } + ] + }, + { + "tag": "colgroup", + "children": [ + { + "tag": "col" + }, + { + "tag": "col" + }, + { + "tag": "col" + } + ] + }, + { + "tag": "colgroup", + "children": [ + { + "tag": "col" + }, + { + "tag": "col" + } + ] + }, + { + "tag": "thead", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><colgroup><col></colgroup><colgroup><col><col><col></colgroup><colgroup><col><col></colgroup><thead><tr><td></td></tr></thead></table></body></html>", + "noQuirksBodyHtml": "<table><colgroup><col></colgroup><colgroup><col><col><col></colgroup><colgroup><col><col></colgroup><thead><tr><td></td></tr></thead></table>" + } + }, + { + "data": "<table><col><tbody><col><tr><col><td><col></table><col>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,37): unexpected-cell-in-table-body", + "(1,55): unexpected-start-tag-ignored" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "colgroup": true, + "col": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "colgroup", + "children": [ + { + "tag": "col" + } + ] + }, + { + "tag": "tbody" + }, + { + "tag": "colgroup", + "children": [ + { + "tag": "col" + } + ] + }, + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + }, + { + "tag": "colgroup", + "children": [ + { + "tag": "col" + } + ] + }, + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ] + }, + { + "tag": "colgroup", + "children": [ + { + "tag": "col" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><colgroup><col></colgroup><tbody></tbody><colgroup><col></colgroup><tbody><tr></tr></tbody><colgroup><col></colgroup><tbody><tr><td></td></tr></tbody><colgroup><col></colgroup></table></body></html>", + "noQuirksBodyHtml": "<table><colgroup><col></colgroup><tbody></tbody><colgroup><col></colgroup><tbody><tr></tr></tbody><colgroup><col></colgroup><tbody><tr><td></td></tr></tbody><colgroup><col></colgroup></table>" + } + }, + { + "data": "<table><colgroup><tbody><colgroup><tr><colgroup><td><colgroup></table><colgroup>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,52): unexpected-cell-in-table-body", + "(1,80): unexpected-start-tag-ignored" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "colgroup": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "colgroup" + }, + { + "tag": "tbody" + }, + { + "tag": "colgroup" + }, + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + }, + { + "tag": "colgroup" + }, + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ] + }, + { + "tag": "colgroup" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><colgroup></colgroup><tbody></tbody><colgroup></colgroup><tbody><tr></tr></tbody><colgroup></colgroup><tbody><tr><td></td></tr></tbody><colgroup></colgroup></table></body></html>", + "noQuirksBodyHtml": "<table><colgroup></colgroup><tbody></tbody><colgroup></colgroup><tbody><tr></tr></tbody><colgroup></colgroup><tbody><tr><td></td></tr></tbody><colgroup></colgroup></table>" + } + }, + { + "data": "</strong></b></em></i></u></strike></s></blink></tt></pre></big></small></font></select></h1></h2></h3></h4></h5></h6></body></br></a></img></title></span></style></script></table></th></td></tr></frame></area></link></param></hr></input></col></base></meta></basefont></bgsound></embed></spacer></p></dd></dt></caption></colgroup></tbody></tfoot></thead></address></blockquote></center></dir></div></dl></fieldset></listing></menu></ol></ul></li></nobr></wbr></form></button></marquee></object></html></frameset></head></iframe></image></isindex></noembed></noframes></noscript></optgroup></option></plaintext></textarea>", + "errors": [ + "(1,9): expected-doctype-but-got-end-tag", + "(1,9): unexpected-end-tag-before-html", + "(1,13): unexpected-end-tag-before-html", + "(1,18): unexpected-end-tag-before-html", + "(1,22): unexpected-end-tag-before-html", + "(1,26): unexpected-end-tag-before-html", + "(1,35): unexpected-end-tag-before-html", + "(1,39): unexpected-end-tag-before-html", + "(1,47): unexpected-end-tag-before-html", + "(1,52): unexpected-end-tag-before-html", + "(1,58): unexpected-end-tag-before-html", + "(1,64): unexpected-end-tag-before-html", + "(1,72): unexpected-end-tag-before-html", + "(1,79): unexpected-end-tag-before-html", + "(1,88): unexpected-end-tag-before-html", + "(1,93): unexpected-end-tag-before-html", + "(1,98): unexpected-end-tag-before-html", + "(1,103): unexpected-end-tag-before-html", + "(1,108): unexpected-end-tag-before-html", + "(1,113): unexpected-end-tag-before-html", + "(1,118): unexpected-end-tag-before-html", + "(1,130): unexpected-end-tag-after-body", + "(1,130): unexpected-end-tag-treated-as", + "(1,134): unexpected-end-tag", + "(1,140): unexpected-end-tag", + "(1,148): unexpected-end-tag", + "(1,155): unexpected-end-tag", + "(1,163): unexpected-end-tag", + "(1,172): unexpected-end-tag", + "(1,180): unexpected-end-tag", + "(1,185): unexpected-end-tag", + "(1,190): unexpected-end-tag", + "(1,195): unexpected-end-tag", + "(1,203): unexpected-end-tag", + "(1,210): unexpected-end-tag", + "(1,217): unexpected-end-tag", + "(1,225): unexpected-end-tag", + "(1,230): unexpected-end-tag", + "(1,238): unexpected-end-tag", + "(1,244): unexpected-end-tag", + "(1,251): unexpected-end-tag", + "(1,258): unexpected-end-tag", + "(1,269): unexpected-end-tag", + "(1,279): unexpected-end-tag", + "(1,287): unexpected-end-tag", + "(1,296): unexpected-end-tag", + "(1,300): unexpected-end-tag", + "(1,305): unexpected-end-tag", + "(1,310): unexpected-end-tag", + "(1,320): unexpected-end-tag", + "(1,331): unexpected-end-tag", + "(1,339): unexpected-end-tag", + "(1,347): unexpected-end-tag", + "(1,355): unexpected-end-tag", + "(1,365): end-tag-too-early", + "(1,378): end-tag-too-early", + "(1,387): end-tag-too-early", + "(1,393): end-tag-too-early", + "(1,399): end-tag-too-early", + "(1,404): end-tag-too-early", + "(1,415): end-tag-too-early", + "(1,425): end-tag-too-early", + "(1,432): end-tag-too-early", + "(1,437): end-tag-too-early", + "(1,442): end-tag-too-early", + "(1,447): unexpected-end-tag", + "(1,454): unexpected-end-tag", + "(1,460): unexpected-end-tag", + "(1,467): unexpected-end-tag", + "(1,476): end-tag-too-early", + "(1,486): end-tag-too-early", + "(1,495): end-tag-too-early", + "(1,513): expected-eof-but-got-end-tag", + "(1,513): unexpected-end-tag", + "(1,520): unexpected-end-tag", + "(1,529): unexpected-end-tag", + "(1,537): unexpected-end-tag", + "(1,547): unexpected-end-tag", + "(1,557): unexpected-end-tag", + "(1,568): unexpected-end-tag", + "(1,579): unexpected-end-tag", + "(1,590): unexpected-end-tag", + "(1,599): unexpected-end-tag", + "(1,611): unexpected-end-tag", + "(1,622): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "br": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "br" + }, + { + "tag": "p" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><br><p></p></body></html>", + "noQuirksBodyHtml": "<br><p></p>" + } + }, + { + "data": "<table><tr></strong></b></em></i></u></strike></s></blink></tt></pre></big></small></font></select></h1></h2></h3></h4></h5></h6></body></br></a></img></title></span></style></script></table></th></td></tr></frame></area></link></param></hr></input></col></base></meta></basefont></bgsound></embed></spacer></p></dd></dt></caption></colgroup></tbody></tfoot></thead></address></blockquote></center></dir></div></dl></fieldset></listing></menu></ol></ul></li></nobr></wbr></form></button></marquee></object></html></frameset></head></iframe></image></isindex></noembed></noframes></noscript></optgroup></option></plaintext></textarea>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,20): unexpected-end-tag-implies-table-voodoo", + "(1,20): unexpected-end-tag", + "(1,24): unexpected-end-tag-implies-table-voodoo", + "(1,24): unexpected-end-tag", + "(1,29): unexpected-end-tag-implies-table-voodoo", + "(1,29): unexpected-end-tag", + "(1,33): unexpected-end-tag-implies-table-voodoo", + "(1,33): unexpected-end-tag", + "(1,37): unexpected-end-tag-implies-table-voodoo", + "(1,37): unexpected-end-tag", + "(1,46): unexpected-end-tag-implies-table-voodoo", + "(1,46): unexpected-end-tag", + "(1,50): unexpected-end-tag-implies-table-voodoo", + "(1,50): unexpected-end-tag", + "(1,58): unexpected-end-tag-implies-table-voodoo", + "(1,58): unexpected-end-tag", + "(1,63): unexpected-end-tag-implies-table-voodoo", + "(1,63): unexpected-end-tag", + "(1,69): unexpected-end-tag-implies-table-voodoo", + "(1,69): end-tag-too-early", + "(1,75): unexpected-end-tag-implies-table-voodoo", + "(1,75): unexpected-end-tag", + "(1,83): unexpected-end-tag-implies-table-voodoo", + "(1,83): unexpected-end-tag", + "(1,90): unexpected-end-tag-implies-table-voodoo", + "(1,90): unexpected-end-tag", + "(1,99): unexpected-end-tag-implies-table-voodoo", + "(1,99): unexpected-end-tag", + "(1,104): unexpected-end-tag-implies-table-voodoo", + "(1,104): end-tag-too-early", + "(1,109): unexpected-end-tag-implies-table-voodoo", + "(1,109): end-tag-too-early", + "(1,114): unexpected-end-tag-implies-table-voodoo", + "(1,114): end-tag-too-early", + "(1,119): unexpected-end-tag-implies-table-voodoo", + "(1,119): end-tag-too-early", + "(1,124): unexpected-end-tag-implies-table-voodoo", + "(1,124): end-tag-too-early", + "(1,129): unexpected-end-tag-implies-table-voodoo", + "(1,129): end-tag-too-early", + "(1,136): unexpected-end-tag-in-table-row", + "(1,141): unexpected-end-tag-implies-table-voodoo", + "(1,141): unexpected-end-tag-treated-as", + "(1,145): unexpected-end-tag-implies-table-voodoo", + "(1,145): unexpected-end-tag", + "(1,151): unexpected-end-tag-implies-table-voodoo", + "(1,151): unexpected-end-tag", + "(1,159): unexpected-end-tag-implies-table-voodoo", + "(1,159): unexpected-end-tag", + "(1,166): unexpected-end-tag-implies-table-voodoo", + "(1,166): unexpected-end-tag", + "(1,174): unexpected-end-tag-implies-table-voodoo", + "(1,174): unexpected-end-tag", + "(1,183): unexpected-end-tag-implies-table-voodoo", + "(1,183): unexpected-end-tag", + "(1,196): unexpected-end-tag", + "(1,201): unexpected-end-tag", + "(1,206): unexpected-end-tag", + "(1,214): unexpected-end-tag", + "(1,221): unexpected-end-tag", + "(1,228): unexpected-end-tag", + "(1,236): unexpected-end-tag", + "(1,241): unexpected-end-tag", + "(1,249): unexpected-end-tag", + "(1,255): unexpected-end-tag", + "(1,262): unexpected-end-tag", + "(1,269): unexpected-end-tag", + "(1,280): unexpected-end-tag", + "(1,290): unexpected-end-tag", + "(1,298): unexpected-end-tag", + "(1,307): unexpected-end-tag", + "(1,311): unexpected-end-tag", + "(1,316): unexpected-end-tag", + "(1,321): unexpected-end-tag", + "(1,331): unexpected-end-tag", + "(1,342): unexpected-end-tag", + "(1,350): unexpected-end-tag", + "(1,358): unexpected-end-tag", + "(1,366): unexpected-end-tag", + "(1,376): end-tag-too-early", + "(1,389): end-tag-too-early", + "(1,398): end-tag-too-early", + "(1,404): end-tag-too-early", + "(1,410): end-tag-too-early", + "(1,415): end-tag-too-early", + "(1,426): end-tag-too-early", + "(1,436): end-tag-too-early", + "(1,443): end-tag-too-early", + "(1,448): end-tag-too-early", + "(1,453): end-tag-too-early", + "(1,458): unexpected-end-tag", + "(1,465): unexpected-end-tag", + "(1,471): unexpected-end-tag", + "(1,478): unexpected-end-tag", + "(1,487): end-tag-too-early", + "(1,497): end-tag-too-early", + "(1,506): end-tag-too-early", + "(1,524): expected-eof-but-got-end-tag", + "(1,524): unexpected-end-tag", + "(1,531): unexpected-end-tag", + "(1,540): unexpected-end-tag", + "(1,548): unexpected-end-tag", + "(1,558): unexpected-end-tag", + "(1,568): unexpected-end-tag", + "(1,579): unexpected-end-tag", + "(1,590): unexpected-end-tag", + "(1,601): unexpected-end-tag", + "(1,610): unexpected-end-tag", + "(1,622): unexpected-end-tag", + "(1,633): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "br": true, + "table": true, + "tbody": true, + "tr": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "br" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + }, + { + "tag": "p" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><br><table><tbody><tr></tr></tbody></table><p></p></body></html>", + "noQuirksBodyHtml": "<br><table><tbody><tr></tr></tbody></table><p></p>" + } + }, + { + "data": "<frameset>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(1,10): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "" + } + } + ], + "tests10.dat": [ + { + "data": "<!DOCTYPE html><svg></svg>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg></svg></body></html>", + "noQuirksBodyHtml": "<svg></svg>" + } + }, + { + "data": "<!DOCTYPE html><svg></svg><![CDATA[a]]>", + "errors": [ + "(1,28) expected-dashes-or-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "doctype": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + }, + { + "comment": "[CDATA[a]]" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg></svg><!--[CDATA[a]]--></body></html>", + "noQuirksBodyHtml": "<svg></svg><!--[CDATA[a]]-->" + } + }, + { + "data": "<!DOCTYPE html><body><svg></svg>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg></svg></body></html>", + "noQuirksBodyHtml": "<svg></svg>" + } + }, + { + "data": "<!DOCTYPE html><body><select><svg></svg></select>", + "errors": [ + "(1,34) unexpected-start-tag-in-select", + "(1,40) unexpected-end-tag-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>", + "noQuirksBodyHtml": "<select></select>" + } + }, + { + "data": "<!DOCTYPE html><body><select><option><svg></svg></option></select>", + "errors": [ + "(1,42) unexpected-start-tag-in-select", + "(1,48) unexpected-end-tag-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "option": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "option" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select><option></option></select></body></html>", + "noQuirksBodyHtml": "<select><option></option></select>" + } + }, + { + "data": "<!DOCTYPE html><body><table><svg></svg></table>", + "errors": [ + "(1,33) foster-parenting-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg></svg><table></table></body></html>", + "noQuirksBodyHtml": "<svg></svg><table></table>" + } + }, + { + "data": "<!DOCTYPE html><body><table><svg><g>foo</g></svg></table>", + "errors": [ + "(1,33) foster-parenting-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg g": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo" + } + ] + } + ] + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg><g>foo</g></svg><table></table></body></html>", + "noQuirksBodyHtml": "<svg><g>foo</g></svg><table></table>" + } + }, + { + "data": "<!DOCTYPE html><body><table><svg><g>foo</g><g>bar</g></svg></table>", + "errors": [ + "(1,33) foster-parenting-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg g": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "bar" + } + ] + } + ] + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg><g>foo</g><g>bar</g></svg><table></table></body></html>", + "noQuirksBodyHtml": "<svg><g>foo</g><g>bar</g></svg><table></table>" + } + }, + { + "data": "<!DOCTYPE html><body><table><tbody><svg><g>foo</g><g>bar</g></svg></tbody></table>", + "errors": [ + "(1,40) foster-parenting-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg g": true, + "table": true, + "tbody": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "bar" + } + ] + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg><g>foo</g><g>bar</g></svg><table><tbody></tbody></table></body></html>", + "noQuirksBodyHtml": "<svg><g>foo</g><g>bar</g></svg><table><tbody></tbody></table>" + } + }, + { + "data": "<!DOCTYPE html><body><table><tbody><tr><svg><g>foo</g><g>bar</g></svg></tr></tbody></table>", + "errors": [ + "(1,44) foster-parenting-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg g": true, + "table": true, + "tbody": true, + "tr": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "bar" + } + ] + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg><g>foo</g><g>bar</g></svg><table><tbody><tr></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<svg><g>foo</g><g>bar</g></svg><table><tbody><tr></tr></tbody></table>" + } + }, + { + "data": "<!DOCTYPE html><body><table><tbody><tr><td><svg><g>foo</g><g>bar</g></svg></td></tr></tbody></table>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "svg svg": true, + "svg g": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "bar" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><svg><g>foo</g><g>bar</g></svg></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><svg><g>foo</g><g>bar</g></svg></td></tr></tbody></table>" + } + }, + { + "data": "<!DOCTYPE html><body><table><tbody><tr><td><svg><g>foo</g><g>bar</g></svg><p>baz</td></tr></tbody></table>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "svg svg": true, + "svg g": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "bar" + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "baz" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><svg><g>foo</g><g>bar</g></svg><p>baz</p></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><svg><g>foo</g><g>bar</g></svg><p>baz</p></td></tr></tbody></table>" + } + }, + { + "data": "<!DOCTYPE html><body><table><caption><svg><g>foo</g><g>bar</g></svg><p>baz</caption></table>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "caption": true, + "svg svg": true, + "svg g": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "caption", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "bar" + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "baz" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><caption><svg><g>foo</g><g>bar</g></svg><p>baz</p></caption></table></body></html>", + "noQuirksBodyHtml": "<table><caption><svg><g>foo</g><g>bar</g></svg><p>baz</p></caption></table>" + } + }, + { + "data": "<!DOCTYPE html><body><table><caption><svg><g>foo</g><g>bar</g><p>baz</table><p>quux", + "errors": [ + "(1,65) unexpected-html-element-in-foreign-content" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "caption": true, + "svg svg": true, + "svg g": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "caption", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "bar" + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "baz" + } + ] + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "quux" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><caption><svg><g>foo</g><g>bar</g></svg><p>baz</p></caption></table><p>quux</p></body></html>", + "noQuirksBodyHtml": "<table><caption><svg><g>foo</g><g>bar</g><p>baz</p></svg></caption></table><p>quux</p>" + } + }, + { + "data": "<!DOCTYPE html><body><table><caption><svg><g>foo</g><g>bar</g>baz</table><p>quux", + "errors": [ + "(1,73) unexpected-end-tag", + "(1,73) expected-one-end-tag-but-got-another" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "caption": true, + "svg svg": true, + "svg g": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "caption", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "bar" + } + ] + }, + { + "text": "baz" + } + ] + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "quux" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><caption><svg><g>foo</g><g>bar</g>baz</svg></caption></table><p>quux</p></body></html>", + "noQuirksBodyHtml": "<table><caption><svg><g>foo</g><g>bar</g>baz</svg></caption></table><p>quux</p>" + } + }, + { + "data": "<!DOCTYPE html><body><table><colgroup><svg><g>foo</g><g>bar</g><p>baz</table><p>quux", + "errors": [ + "(1,43) foster-parenting-start-tag svg", + "(1,66) unexpected HTML-like start tag token in foreign content", + "(1,66) foster-parenting-start-tag", + "(1,67) foster-parenting-character", + "(1,68) foster-parenting-character", + "(1,69) foster-parenting-character" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg g": true, + "p": true, + "table": true, + "colgroup": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "bar" + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "baz" + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "colgroup" + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "quux" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg><g>foo</g><g>bar</g></svg><p>baz</p><table><colgroup></colgroup></table><p>quux</p></body></html>", + "noQuirksBodyHtml": "<svg><g>foo</g><g>bar</g><p>baz</p></svg><table><colgroup></colgroup></table><p>quux</p>" + } + }, + { + "data": "<!DOCTYPE html><body><table><tr><td><select><svg><g>foo</g><g>bar</g><p>baz</table><p>quux", + "errors": [ + "(1,49) unexpected-start-tag-in-select", + "(1,52) unexpected-start-tag-in-select", + "(1,59) unexpected-end-tag-in-select", + "(1,62) unexpected-start-tag-in-select", + "(1,69) unexpected-end-tag-in-select", + "(1,72) unexpected-start-tag-in-select", + "(1,83) unexpected-table-element-end-tag-in-select-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "select": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "select", + "children": [ + { + "text": "foobarbaz" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "quux" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><select>foobarbaz</select></td></tr></tbody></table><p>quux</p></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><select>foobarbaz</select></td></tr></tbody></table><p>quux</p>" + } + }, + { + "data": "<!DOCTYPE html><body><table><select><svg><g>foo</g><g>bar</g><p>baz</table><p>quux", + "errors": [ + "(1,36) unexpected-start-tag-implies-table-voodoo", + "(1,41) unexpected-start-tag-in-select", + "(1,44) unexpected-start-tag-in-select", + "(1,51) unexpected-end-tag-in-select", + "(1,54) unexpected-start-tag-in-select", + "(1,61) unexpected-end-tag-in-select", + "(1,64) unexpected-start-tag-in-select", + "(1,75) unexpected-table-element-end-tag-in-select-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "table": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "text": "foobarbaz" + } + ] + }, + { + "tag": "table" + }, + { + "tag": "p", + "children": [ + { + "text": "quux" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select>foobarbaz</select><table></table><p>quux</p></body></html>", + "noQuirksBodyHtml": "<select>foobarbaz</select><table></table><p>quux</p>" + } + }, + { + "data": "<!DOCTYPE html><body></body></html><svg><g>foo</g><g>bar</g><p>baz", + "errors": [ + "(1,40) expected-eof-but-got-start-tag", + "(1,63) unexpected-html-element-in-foreign-content" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg g": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "bar" + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "baz" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg><g>foo</g><g>bar</g></svg><p>baz</p></body></html>", + "noQuirksBodyHtml": "<svg><g>foo</g><g>bar</g><p>baz</p></svg>" + } + }, + { + "data": "<!DOCTYPE html><body></body><svg><g>foo</g><g>bar</g><p>baz", + "errors": [ + "(1,33) unexpected-start-tag-after-body", + "(1,56) unexpected-html-element-in-foreign-content" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg g": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "bar" + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "baz" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg><g>foo</g><g>bar</g></svg><p>baz</p></body></html>", + "noQuirksBodyHtml": "<svg><g>foo</g><g>bar</g><p>baz</p></svg>" + } + }, + { + "data": "<!DOCTYPE html><frameset><svg><g></g><g></g><p><span>", + "errors": [ + "(1,30) unexpected-start-tag-in-frameset", + "(1,33) unexpected-start-tag-in-frameset", + "(1,37) unexpected-end-tag-in-frameset", + "(1,40) unexpected-start-tag-in-frameset", + "(1,44) unexpected-end-tag-in-frameset", + "(1,47) unexpected-start-tag-in-frameset", + "(1,53) unexpected-start-tag-in-frameset", + "(1,53) eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<svg><g></g><g></g><p><span></span></p></svg>" + } + }, + { + "data": "<!DOCTYPE html><frameset></frameset><svg><g></g><g></g><p><span>", + "errors": [ + "(1,41) unexpected-start-tag-after-frameset", + "(1,44) unexpected-start-tag-after-frameset", + "(1,48) unexpected-end-tag-after-frameset", + "(1,51) unexpected-start-tag-after-frameset", + "(1,55) unexpected-end-tag-after-frameset", + "(1,58) unexpected-start-tag-after-frameset", + "(1,64) unexpected-start-tag-after-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<svg><g></g><g></g><p><span></span></p></svg>" + } + }, + { + "data": "<!DOCTYPE html><body xlink:href=foo><svg xlink:href=foo></svg>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "attrs": [ + { + "name": "xlink:href", + "value": "foo" + } + ], + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "attrs": [ + { + "name": "href", + "ns": "http://www.w3.org/1999/xlink", + "value": "foo" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body xlink:href=\"foo\"><svg xlink:href=\"foo\"></svg></body></html>", + "noQuirksBodyHtml": "<svg xlink:href=\"foo\"></svg>" + } + }, + { + "data": "<!DOCTYPE html><body xlink:href=foo xml:lang=en><svg><g xml:lang=en xlink:href=foo></g></svg>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg g": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "attrs": [ + { + "name": "xlink:href", + "value": "foo" + }, + { + "name": "xml:lang", + "value": "en" + } + ], + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "attrs": [ + { + "name": "href", + "ns": "http://www.w3.org/1999/xlink", + "value": "foo" + }, + { + "name": "lang", + "ns": "http://www.w3.org/XML/1998/namespace", + "value": "en" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body xlink:href=\"foo\" xml:lang=\"en\"><svg><g xml:lang=\"en\" xlink:href=\"foo\"></g></svg></body></html>", + "noQuirksBodyHtml": "<svg><g xml:lang=\"en\" xlink:href=\"foo\"></g></svg>" + } + }, + { + "data": "<!DOCTYPE html><body xlink:href=foo xml:lang=en><svg><g xml:lang=en xlink:href=foo /></svg>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg g": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "attrs": [ + { + "name": "xlink:href", + "value": "foo" + }, + { + "name": "xml:lang", + "value": "en" + } + ], + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "attrs": [ + { + "name": "href", + "ns": "http://www.w3.org/1999/xlink", + "value": "foo" + }, + { + "name": "lang", + "ns": "http://www.w3.org/XML/1998/namespace", + "value": "en" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body xlink:href=\"foo\" xml:lang=\"en\"><svg><g xml:lang=\"en\" xlink:href=\"foo\"></g></svg></body></html>", + "noQuirksBodyHtml": "<svg><g xml:lang=\"en\" xlink:href=\"foo\"></g></svg>" + } + }, + { + "data": "<!DOCTYPE html><body xlink:href=foo xml:lang=en><svg><g xml:lang=en xlink:href=foo />bar</svg>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg g": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "attrs": [ + { + "name": "xlink:href", + "value": "foo" + }, + { + "name": "xml:lang", + "value": "en" + } + ], + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "attrs": [ + { + "name": "href", + "ns": "http://www.w3.org/1999/xlink", + "value": "foo" + }, + { + "name": "lang", + "ns": "http://www.w3.org/XML/1998/namespace", + "value": "en" + } + ] + }, + { + "text": "bar" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body xlink:href=\"foo\" xml:lang=\"en\"><svg><g xml:lang=\"en\" xlink:href=\"foo\"></g>bar</svg></body></html>", + "noQuirksBodyHtml": "<svg><g xml:lang=\"en\" xlink:href=\"foo\"></g>bar</svg>" + } + }, + { + "data": "<svg></path>", + "errors": [ + "(1,5) expected-doctype-but-got-start-tag", + "(1,12) unexpected-end-tag", + "(1,12) unexpected-end-tag", + "(1,12) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg></svg></body></html>", + "noQuirksBodyHtml": "<svg></svg>" + } + }, + { + "data": "<div><svg></div>a", + "errors": [ + "(1,5) expected-doctype-but-got-start-tag", + "(1,16) unexpected-end-tag", + "(1,16) end-tag-too-early" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "svg svg": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + } + ] + }, + { + "text": "a" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div><svg></svg></div>a</body></html>", + "noQuirksBodyHtml": "<div><svg></svg></div>a" + } + }, + { + "data": "<div><svg><path></div>a", + "errors": [ + "(1,5) expected-doctype-but-got-start-tag", + "(1,22) unexpected-end-tag", + "(1,22) end-tag-too-early" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "svg svg": true, + "svg path": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "path", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + }, + { + "text": "a" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div><svg><path></path></svg></div>a</body></html>", + "noQuirksBodyHtml": "<div><svg><path></path></svg></div>a" + } + }, + { + "data": "<div><svg><path></svg><path>", + "errors": [ + "(1,5) expected-doctype-but-got-start-tag", + "(1,22) unexpected-end-tag", + "(1,28) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "svg svg": true, + "svg path": true, + "path": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "path", + "ns": "http://www.w3.org/2000/svg" + } + ] + }, + { + "tag": "path" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div><svg><path></path></svg><path></path></div></body></html>", + "noQuirksBodyHtml": "<div><svg><path></path></svg><path></path></div>" + } + }, + { + "data": "<div><svg><path><foreignObject><math></div>a", + "errors": [ + "(1,5) expected-doctype-but-got-start-tag", + "(1,43) unexpected-end-tag", + "(1,43) end-tag-too-early", + "(1,44) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "svg svg": true, + "svg path": true, + "svg foreignObject": true, + "math math": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "path", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div><svg><path><foreignObject><math>a</math></foreignObject></path></svg></div></body></html>", + "noQuirksBodyHtml": "<div><svg><path><foreignObject><math>a</math></foreignObject></path></svg></div>" + } + }, + { + "data": "<div><svg><path><foreignObject><p></div>a", + "errors": [ + "(1,5) expected-doctype-but-got-start-tag", + "(1,40) end-tag-too-early", + "(1,41) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "svg svg": true, + "svg path": true, + "svg foreignObject": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "path", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "p", + "children": [ + { + "text": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div><svg><path><foreignObject><p>a</p></foreignObject></path></svg></div></body></html>", + "noQuirksBodyHtml": "<div><svg><path><foreignObject><p>a</p></foreignObject></path></svg></div>" + } + }, + { + "data": "<!DOCTYPE html><svg><desc><div><svg><ul>a", + "errors": [ + "(1,40) unexpected-html-element-in-foreign-content", + "(1,41) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg desc": true, + "div": true, + "ul": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "desc", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "ul", + "children": [ + { + "text": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg><desc><div><svg></svg><ul>a</ul></div></desc></svg></body></html>", + "noQuirksBodyHtml": "<svg><desc><div><svg><ul>a</ul></svg></div></desc></svg>" + } + }, + { + "data": "<!DOCTYPE html><svg><desc><svg><ul>a", + "errors": [ + "(1,35) unexpected-html-element-in-foreign-content", + "(1,36) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg desc": true, + "ul": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "desc", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "ul", + "children": [ + { + "text": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg><desc><svg></svg><ul>a</ul></desc></svg></body></html>", + "noQuirksBodyHtml": "<svg><desc><svg><ul>a</ul></svg></desc></svg>" + } + }, + { + "data": "<!DOCTYPE html><p><svg><desc><p>", + "errors": [ + "(1,32) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "svg svg": true, + "svg desc": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "desc", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "p" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><svg><desc><p></p></desc></svg></p></body></html>", + "noQuirksBodyHtml": "<p><svg><desc><p></p></desc></svg></p>" + } + }, + { + "data": "<!DOCTYPE html><p><svg><title><p>", + "errors": [ + "(1,33) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "svg svg": true, + "svg title": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "title", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "p" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><svg><title><p></p></title></svg></p></body></html>", + "noQuirksBodyHtml": "<p><svg><title><p></p></title></svg></p>" + } + }, + { + "data": "<div><svg><path><foreignObject><p></foreignObject><p>", + "errors": [ + "(1,5) expected-doctype-but-got-start-tag", + "(1,50) unexpected-end-tag", + "(1,53) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "svg svg": true, + "svg path": true, + "svg foreignObject": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "path", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "p" + }, + { + "tag": "p" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div><svg><path><foreignObject><p></p><p></p></foreignObject></path></svg></div></body></html>", + "noQuirksBodyHtml": "<div><svg><path><foreignObject><p></p><p></p></foreignObject></path></svg></div>" + } + }, + { + "data": "<math><mi><div><object><div><span></span></div></object></div></mi><mi>", + "errors": [ + "(1,6) expected-doctype-but-got-start-tag", + "(1,71) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mi": true, + "div": true, + "object": true, + "span": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "object", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "span" + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><mi><div><object><div><span></span></div></object></div></mi><mi></mi></math></body></html>", + "noQuirksBodyHtml": "<math><mi><div><object><div><span></span></div></object></div></mi><mi></mi></math>" + } + }, + { + "data": "<math><mi><svg><foreignObject><div><div></div></div></foreignObject></svg></mi><mi>", + "errors": [ + "(1,6) expected-doctype-but-got-start-tag", + "(1,83) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mi": true, + "svg svg": true, + "svg foreignObject": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><mi><svg><foreignObject><div><div></div></div></foreignObject></svg></mi><mi></mi></math></body></html>", + "noQuirksBodyHtml": "<math><mi><svg><foreignObject><div><div></div></div></foreignObject></svg></mi><mi></mi></math>" + } + }, + { + "data": "<svg><script></script><path>", + "errors": [ + "(1,5) expected-doctype-but-got-start-tag", + "(1,28) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg script": true, + "svg path": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "script", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "path", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg><script></script><path></path></svg></body></html>", + "noQuirksBodyHtml": "<svg><script></script><path></path></svg>" + } + }, + { + "data": "<table><svg></svg><tr>", + "errors": [ + "(1,7) expected-doctype-but-got-start-tag", + "(1,12) unexpected-start-tag-implies-table-voodoo", + "(1,22) eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "table": true, + "tbody": true, + "tr": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg></svg><table><tbody><tr></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<svg></svg><table><tbody><tr></tr></tbody></table>" + } + }, + { + "data": "<math><mi><mglyph>", + "errors": [ + "(1,6) expected-doctype-but-got-start-tag", + "(1,18) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mi": true, + "math mglyph": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mglyph", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><mi><mglyph></mglyph></mi></math></body></html>", + "noQuirksBodyHtml": "<math><mi><mglyph></mglyph></mi></math>" + } + }, + { + "data": "<math><mi><malignmark>", + "errors": [ + "(1,6) expected-doctype-but-got-start-tag", + "(1,22) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mi": true, + "math malignmark": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "malignmark", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><mi><malignmark></malignmark></mi></math></body></html>", + "noQuirksBodyHtml": "<math><mi><malignmark></malignmark></mi></math>" + } + }, + { + "data": "<math><mo><mglyph>", + "errors": [ + "(1,6) expected-doctype-but-got-start-tag", + "(1,18) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mo": true, + "math mglyph": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mo", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mglyph", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><mo><mglyph></mglyph></mo></math></body></html>", + "noQuirksBodyHtml": "<math><mo><mglyph></mglyph></mo></math>" + } + }, + { + "data": "<math><mo><malignmark>", + "errors": [ + "(1,6) expected-doctype-but-got-start-tag", + "(1,22) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mo": true, + "math malignmark": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mo", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "malignmark", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><mo><malignmark></malignmark></mo></math></body></html>", + "noQuirksBodyHtml": "<math><mo><malignmark></malignmark></mo></math>" + } + }, + { + "data": "<math><mn><mglyph>", + "errors": [ + "(1,6) expected-doctype-but-got-start-tag", + "(1,18) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mn": true, + "math mglyph": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mn", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mglyph", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><mn><mglyph></mglyph></mn></math></body></html>", + "noQuirksBodyHtml": "<math><mn><mglyph></mglyph></mn></math>" + } + }, + { + "data": "<math><mn><malignmark>", + "errors": [ + "(1,6) expected-doctype-but-got-start-tag", + "(1,22) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mn": true, + "math malignmark": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mn", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "malignmark", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><mn><malignmark></malignmark></mn></math></body></html>", + "noQuirksBodyHtml": "<math><mn><malignmark></malignmark></mn></math>" + } + }, + { + "data": "<math><ms><mglyph>", + "errors": [ + "(1,6) expected-doctype-but-got-start-tag", + "(1,18) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math ms": true, + "math mglyph": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "ms", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mglyph", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><ms><mglyph></mglyph></ms></math></body></html>", + "noQuirksBodyHtml": "<math><ms><mglyph></mglyph></ms></math>" + } + }, + { + "data": "<math><ms><malignmark>", + "errors": [ + "(1,6) expected-doctype-but-got-start-tag", + "(1,22) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math ms": true, + "math malignmark": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "ms", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "malignmark", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><ms><malignmark></malignmark></ms></math></body></html>", + "noQuirksBodyHtml": "<math><ms><malignmark></malignmark></ms></math>" + } + }, + { + "data": "<math><mtext><mglyph>", + "errors": [ + "(1,6) expected-doctype-but-got-start-tag", + "(1,21) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mtext": true, + "math mglyph": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mtext", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mglyph", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><mtext><mglyph></mglyph></mtext></math></body></html>", + "noQuirksBodyHtml": "<math><mtext><mglyph></mglyph></mtext></math>" + } + }, + { + "data": "<math><mtext><malignmark>", + "errors": [ + "(1,6) expected-doctype-but-got-start-tag", + "(1,25) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mtext": true, + "math malignmark": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mtext", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "malignmark", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><mtext><malignmark></malignmark></mtext></math></body></html>", + "noQuirksBodyHtml": "<math><mtext><malignmark></malignmark></mtext></math>" + } + }, + { + "data": "<math><annotation-xml><svg></svg></annotation-xml><mi>", + "errors": [ + "(1,6) expected-doctype-but-got-start-tag", + "(1,54) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math annotation-xml": true, + "svg svg": true, + "math mi": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "annotation-xml", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + } + ] + }, + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><annotation-xml><svg></svg></annotation-xml><mi></mi></math></body></html>", + "noQuirksBodyHtml": "<math><annotation-xml><svg></svg></annotation-xml><mi></mi></math>" + } + }, + { + "data": "<math><annotation-xml><svg><foreignObject><div><math><mi></mi></math><span></span></div></foreignObject><path></path></svg></annotation-xml><mi>", + "errors": [ + "(1,6) expected-doctype-but-got-start-tag", + "(1,144) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math annotation-xml": true, + "svg svg": true, + "svg foreignObject": true, + "div": true, + "math mi": true, + "span": true, + "svg path": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "annotation-xml", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + }, + { + "tag": "span" + } + ] + } + ] + }, + { + "tag": "path", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + }, + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><annotation-xml><svg><foreignObject><div><math><mi></mi></math><span></span></div></foreignObject><path></path></svg></annotation-xml><mi></mi></math></body></html>", + "noQuirksBodyHtml": "<math><annotation-xml><svg><foreignObject><div><math><mi></mi></math><span></span></div></foreignObject><path></path></svg></annotation-xml><mi></mi></math>" + } + }, + { + "data": "<math><annotation-xml><svg><foreignObject><math><mi><svg></svg></mi><mo></mo></math><span></span></foreignObject><path></path></svg></annotation-xml><mi>", + "errors": [ + "(1,6) expected-doctype-but-got-start-tag", + "(1,153) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math annotation-xml": true, + "svg svg": true, + "svg foreignObject": true, + "math mi": true, + "math mo": true, + "span": true, + "svg path": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "annotation-xml", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + } + ] + }, + { + "tag": "mo", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + }, + { + "tag": "span" + } + ] + }, + { + "tag": "path", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + }, + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><annotation-xml><svg><foreignObject><math><mi><svg></svg></mi><mo></mo></math><span></span></foreignObject><path></path></svg></annotation-xml><mi></mi></math></body></html>", + "noQuirksBodyHtml": "<math><annotation-xml><svg><foreignObject><math><mi><svg></svg></mi><mo></mo></math><span></span></foreignObject><path></path></svg></annotation-xml><mi></mi></math>" + } + } + ], + "tests11.dat": [ + { + "data": "<!DOCTYPE html><body><svg attributeName='' attributeType='' baseFrequency='' baseProfile='' calcMode='' clipPathUnits='' diffuseConstant='' edgeMode='' filterUnits='' glyphRef='' gradientTransform='' gradientUnits='' kernelMatrix='' kernelUnitLength='' keyPoints='' keySplines='' keyTimes='' lengthAdjust='' limitingConeAngle='' markerHeight='' markerUnits='' markerWidth='' maskContentUnits='' maskUnits='' numOctaves='' pathLength='' patternContentUnits='' patternTransform='' patternUnits='' pointsAtX='' pointsAtY='' pointsAtZ='' preserveAlpha='' preserveAspectRatio='' primitiveUnits='' refX='' refY='' repeatCount='' repeatDur='' requiredExtensions='' requiredFeatures='' specularConstant='' specularExponent='' spreadMethod='' startOffset='' stdDeviation='' stitchTiles='' surfaceScale='' systemLanguage='' tableValues='' targetX='' targetY='' textLength='' viewBox='' viewTarget='' xChannelSelector='' yChannelSelector='' zoomAndPan=''></svg>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "attrs": [ + { + "name": "attributeName", + "value": "" + }, + { + "name": "attributeType", + "value": "" + }, + { + "name": "baseFrequency", + "value": "" + }, + { + "name": "baseProfile", + "value": "" + }, + { + "name": "calcMode", + "value": "" + }, + { + "name": "clipPathUnits", + "value": "" + }, + { + "name": "diffuseConstant", + "value": "" + }, + { + "name": "edgeMode", + "value": "" + }, + { + "name": "filterUnits", + "value": "" + }, + { + "name": "glyphRef", + "value": "" + }, + { + "name": "gradientTransform", + "value": "" + }, + { + "name": "gradientUnits", + "value": "" + }, + { + "name": "kernelMatrix", + "value": "" + }, + { + "name": "kernelUnitLength", + "value": "" + }, + { + "name": "keyPoints", + "value": "" + }, + { + "name": "keySplines", + "value": "" + }, + { + "name": "keyTimes", + "value": "" + }, + { + "name": "lengthAdjust", + "value": "" + }, + { + "name": "limitingConeAngle", + "value": "" + }, + { + "name": "markerHeight", + "value": "" + }, + { + "name": "markerUnits", + "value": "" + }, + { + "name": "markerWidth", + "value": "" + }, + { + "name": "maskContentUnits", + "value": "" + }, + { + "name": "maskUnits", + "value": "" + }, + { + "name": "numOctaves", + "value": "" + }, + { + "name": "pathLength", + "value": "" + }, + { + "name": "patternContentUnits", + "value": "" + }, + { + "name": "patternTransform", + "value": "" + }, + { + "name": "patternUnits", + "value": "" + }, + { + "name": "pointsAtX", + "value": "" + }, + { + "name": "pointsAtY", + "value": "" + }, + { + "name": "pointsAtZ", + "value": "" + }, + { + "name": "preserveAlpha", + "value": "" + }, + { + "name": "preserveAspectRatio", + "value": "" + }, + { + "name": "primitiveUnits", + "value": "" + }, + { + "name": "refX", + "value": "" + }, + { + "name": "refY", + "value": "" + }, + { + "name": "repeatCount", + "value": "" + }, + { + "name": "repeatDur", + "value": "" + }, + { + "name": "requiredExtensions", + "value": "" + }, + { + "name": "requiredFeatures", + "value": "" + }, + { + "name": "specularConstant", + "value": "" + }, + { + "name": "specularExponent", + "value": "" + }, + { + "name": "spreadMethod", + "value": "" + }, + { + "name": "startOffset", + "value": "" + }, + { + "name": "stdDeviation", + "value": "" + }, + { + "name": "stitchTiles", + "value": "" + }, + { + "name": "surfaceScale", + "value": "" + }, + { + "name": "systemLanguage", + "value": "" + }, + { + "name": "tableValues", + "value": "" + }, + { + "name": "targetX", + "value": "" + }, + { + "name": "targetY", + "value": "" + }, + { + "name": "textLength", + "value": "" + }, + { + "name": "viewBox", + "value": "" + }, + { + "name": "viewTarget", + "value": "" + }, + { + "name": "xChannelSelector", + "value": "" + }, + { + "name": "yChannelSelector", + "value": "" + }, + { + "name": "zoomAndPan", + "value": "" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg attributeName=\"\" attributeType=\"\" baseFrequency=\"\" baseProfile=\"\" calcMode=\"\" clipPathUnits=\"\" diffuseConstant=\"\" edgeMode=\"\" filterUnits=\"\" glyphRef=\"\" gradientTransform=\"\" gradientUnits=\"\" kernelMatrix=\"\" kernelUnitLength=\"\" keyPoints=\"\" keySplines=\"\" keyTimes=\"\" lengthAdjust=\"\" limitingConeAngle=\"\" markerHeight=\"\" markerUnits=\"\" markerWidth=\"\" maskContentUnits=\"\" maskUnits=\"\" numOctaves=\"\" pathLength=\"\" patternContentUnits=\"\" patternTransform=\"\" patternUnits=\"\" pointsAtX=\"\" pointsAtY=\"\" pointsAtZ=\"\" preserveAlpha=\"\" preserveAspectRatio=\"\" primitiveUnits=\"\" refX=\"\" refY=\"\" repeatCount=\"\" repeatDur=\"\" requiredExtensions=\"\" requiredFeatures=\"\" specularConstant=\"\" specularExponent=\"\" spreadMethod=\"\" startOffset=\"\" stdDeviation=\"\" stitchTiles=\"\" surfaceScale=\"\" systemLanguage=\"\" tableValues=\"\" targetX=\"\" targetY=\"\" textLength=\"\" viewBox=\"\" viewTarget=\"\" xChannelSelector=\"\" yChannelSelector=\"\" zoomAndPan=\"\"></svg></body></html>", + "noQuirksBodyHtml": "<svg attributeName=\"\" attributeType=\"\" baseFrequency=\"\" baseProfile=\"\" calcMode=\"\" clipPathUnits=\"\" diffuseConstant=\"\" edgeMode=\"\" filterUnits=\"\" glyphRef=\"\" gradientTransform=\"\" gradientUnits=\"\" kernelMatrix=\"\" kernelUnitLength=\"\" keyPoints=\"\" keySplines=\"\" keyTimes=\"\" lengthAdjust=\"\" limitingConeAngle=\"\" markerHeight=\"\" markerUnits=\"\" markerWidth=\"\" maskContentUnits=\"\" maskUnits=\"\" numOctaves=\"\" pathLength=\"\" patternContentUnits=\"\" patternTransform=\"\" patternUnits=\"\" pointsAtX=\"\" pointsAtY=\"\" pointsAtZ=\"\" preserveAlpha=\"\" preserveAspectRatio=\"\" primitiveUnits=\"\" refX=\"\" refY=\"\" repeatCount=\"\" repeatDur=\"\" requiredExtensions=\"\" requiredFeatures=\"\" specularConstant=\"\" specularExponent=\"\" spreadMethod=\"\" startOffset=\"\" stdDeviation=\"\" stitchTiles=\"\" surfaceScale=\"\" systemLanguage=\"\" tableValues=\"\" targetX=\"\" targetY=\"\" textLength=\"\" viewBox=\"\" viewTarget=\"\" xChannelSelector=\"\" yChannelSelector=\"\" zoomAndPan=\"\"></svg>" + } + }, + { + "data": "<!DOCTYPE html><BODY><SVG ATTRIBUTENAME='' ATTRIBUTETYPE='' BASEFREQUENCY='' BASEPROFILE='' CALCMODE='' CLIPPATHUNITS='' DIFFUSECONSTANT='' EDGEMODE='' FILTERUNITS='' GLYPHREF='' GRADIENTTRANSFORM='' GRADIENTUNITS='' KERNELMATRIX='' KERNELUNITLENGTH='' KEYPOINTS='' KEYSPLINES='' KEYTIMES='' LENGTHADJUST='' LIMITINGCONEANGLE='' MARKERHEIGHT='' MARKERUNITS='' MARKERWIDTH='' MASKCONTENTUNITS='' MASKUNITS='' NUMOCTAVES='' PATHLENGTH='' PATTERNCONTENTUNITS='' PATTERNTRANSFORM='' PATTERNUNITS='' POINTSATX='' POINTSATY='' POINTSATZ='' PRESERVEALPHA='' PRESERVEASPECTRATIO='' PRIMITIVEUNITS='' REFX='' REFY='' REPEATCOUNT='' REPEATDUR='' REQUIREDEXTENSIONS='' REQUIREDFEATURES='' SPECULARCONSTANT='' SPECULAREXPONENT='' SPREADMETHOD='' STARTOFFSET='' STDDEVIATION='' STITCHTILES='' SURFACESCALE='' SYSTEMLANGUAGE='' TABLEVALUES='' TARGETX='' TARGETY='' TEXTLENGTH='' VIEWBOX='' VIEWTARGET='' XCHANNELSELECTOR='' YCHANNELSELECTOR='' ZOOMANDPAN=''></SVG>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "attrs": [ + { + "name": "attributeName", + "value": "" + }, + { + "name": "attributeType", + "value": "" + }, + { + "name": "baseFrequency", + "value": "" + }, + { + "name": "baseProfile", + "value": "" + }, + { + "name": "calcMode", + "value": "" + }, + { + "name": "clipPathUnits", + "value": "" + }, + { + "name": "diffuseConstant", + "value": "" + }, + { + "name": "edgeMode", + "value": "" + }, + { + "name": "filterUnits", + "value": "" + }, + { + "name": "glyphRef", + "value": "" + }, + { + "name": "gradientTransform", + "value": "" + }, + { + "name": "gradientUnits", + "value": "" + }, + { + "name": "kernelMatrix", + "value": "" + }, + { + "name": "kernelUnitLength", + "value": "" + }, + { + "name": "keyPoints", + "value": "" + }, + { + "name": "keySplines", + "value": "" + }, + { + "name": "keyTimes", + "value": "" + }, + { + "name": "lengthAdjust", + "value": "" + }, + { + "name": "limitingConeAngle", + "value": "" + }, + { + "name": "markerHeight", + "value": "" + }, + { + "name": "markerUnits", + "value": "" + }, + { + "name": "markerWidth", + "value": "" + }, + { + "name": "maskContentUnits", + "value": "" + }, + { + "name": "maskUnits", + "value": "" + }, + { + "name": "numOctaves", + "value": "" + }, + { + "name": "pathLength", + "value": "" + }, + { + "name": "patternContentUnits", + "value": "" + }, + { + "name": "patternTransform", + "value": "" + }, + { + "name": "patternUnits", + "value": "" + }, + { + "name": "pointsAtX", + "value": "" + }, + { + "name": "pointsAtY", + "value": "" + }, + { + "name": "pointsAtZ", + "value": "" + }, + { + "name": "preserveAlpha", + "value": "" + }, + { + "name": "preserveAspectRatio", + "value": "" + }, + { + "name": "primitiveUnits", + "value": "" + }, + { + "name": "refX", + "value": "" + }, + { + "name": "refY", + "value": "" + }, + { + "name": "repeatCount", + "value": "" + }, + { + "name": "repeatDur", + "value": "" + }, + { + "name": "requiredExtensions", + "value": "" + }, + { + "name": "requiredFeatures", + "value": "" + }, + { + "name": "specularConstant", + "value": "" + }, + { + "name": "specularExponent", + "value": "" + }, + { + "name": "spreadMethod", + "value": "" + }, + { + "name": "startOffset", + "value": "" + }, + { + "name": "stdDeviation", + "value": "" + }, + { + "name": "stitchTiles", + "value": "" + }, + { + "name": "surfaceScale", + "value": "" + }, + { + "name": "systemLanguage", + "value": "" + }, + { + "name": "tableValues", + "value": "" + }, + { + "name": "targetX", + "value": "" + }, + { + "name": "targetY", + "value": "" + }, + { + "name": "textLength", + "value": "" + }, + { + "name": "viewBox", + "value": "" + }, + { + "name": "viewTarget", + "value": "" + }, + { + "name": "xChannelSelector", + "value": "" + }, + { + "name": "yChannelSelector", + "value": "" + }, + { + "name": "zoomAndPan", + "value": "" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg attributeName=\"\" attributeType=\"\" baseFrequency=\"\" baseProfile=\"\" calcMode=\"\" clipPathUnits=\"\" diffuseConstant=\"\" edgeMode=\"\" filterUnits=\"\" glyphRef=\"\" gradientTransform=\"\" gradientUnits=\"\" kernelMatrix=\"\" kernelUnitLength=\"\" keyPoints=\"\" keySplines=\"\" keyTimes=\"\" lengthAdjust=\"\" limitingConeAngle=\"\" markerHeight=\"\" markerUnits=\"\" markerWidth=\"\" maskContentUnits=\"\" maskUnits=\"\" numOctaves=\"\" pathLength=\"\" patternContentUnits=\"\" patternTransform=\"\" patternUnits=\"\" pointsAtX=\"\" pointsAtY=\"\" pointsAtZ=\"\" preserveAlpha=\"\" preserveAspectRatio=\"\" primitiveUnits=\"\" refX=\"\" refY=\"\" repeatCount=\"\" repeatDur=\"\" requiredExtensions=\"\" requiredFeatures=\"\" specularConstant=\"\" specularExponent=\"\" spreadMethod=\"\" startOffset=\"\" stdDeviation=\"\" stitchTiles=\"\" surfaceScale=\"\" systemLanguage=\"\" tableValues=\"\" targetX=\"\" targetY=\"\" textLength=\"\" viewBox=\"\" viewTarget=\"\" xChannelSelector=\"\" yChannelSelector=\"\" zoomAndPan=\"\"></svg></body></html>", + "noQuirksBodyHtml": "<svg attributeName=\"\" attributeType=\"\" baseFrequency=\"\" baseProfile=\"\" calcMode=\"\" clipPathUnits=\"\" diffuseConstant=\"\" edgeMode=\"\" filterUnits=\"\" glyphRef=\"\" gradientTransform=\"\" gradientUnits=\"\" kernelMatrix=\"\" kernelUnitLength=\"\" keyPoints=\"\" keySplines=\"\" keyTimes=\"\" lengthAdjust=\"\" limitingConeAngle=\"\" markerHeight=\"\" markerUnits=\"\" markerWidth=\"\" maskContentUnits=\"\" maskUnits=\"\" numOctaves=\"\" pathLength=\"\" patternContentUnits=\"\" patternTransform=\"\" patternUnits=\"\" pointsAtX=\"\" pointsAtY=\"\" pointsAtZ=\"\" preserveAlpha=\"\" preserveAspectRatio=\"\" primitiveUnits=\"\" refX=\"\" refY=\"\" repeatCount=\"\" repeatDur=\"\" requiredExtensions=\"\" requiredFeatures=\"\" specularConstant=\"\" specularExponent=\"\" spreadMethod=\"\" startOffset=\"\" stdDeviation=\"\" stitchTiles=\"\" surfaceScale=\"\" systemLanguage=\"\" tableValues=\"\" targetX=\"\" targetY=\"\" textLength=\"\" viewBox=\"\" viewTarget=\"\" xChannelSelector=\"\" yChannelSelector=\"\" zoomAndPan=\"\"></svg>" + } + }, + { + "data": "<!DOCTYPE html><body><svg attributename='' attributetype='' basefrequency='' baseprofile='' calcmode='' clippathunits='' diffuseconstant='' edgemode='' filterunits='' filterres='' glyphref='' gradienttransform='' gradientunits='' kernelmatrix='' kernelunitlength='' keypoints='' keysplines='' keytimes='' lengthadjust='' limitingconeangle='' markerheight='' markerunits='' markerwidth='' maskcontentunits='' maskunits='' numoctaves='' pathlength='' patterncontentunits='' patterntransform='' patternunits='' pointsatx='' pointsaty='' pointsatz='' preservealpha='' preserveaspectratio='' primitiveunits='' refx='' refy='' repeatcount='' repeatdur='' requiredextensions='' requiredfeatures='' specularconstant='' specularexponent='' spreadmethod='' startoffset='' stddeviation='' stitchtiles='' surfacescale='' systemlanguage='' tablevalues='' targetx='' targety='' textlength='' viewbox='' viewtarget='' xchannelselector='' ychannelselector='' zoomandpan=''></svg>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "attrs": [ + { + "name": "attributeName", + "value": "" + }, + { + "name": "attributeType", + "value": "" + }, + { + "name": "baseFrequency", + "value": "" + }, + { + "name": "baseProfile", + "value": "" + }, + { + "name": "calcMode", + "value": "" + }, + { + "name": "clipPathUnits", + "value": "" + }, + { + "name": "diffuseConstant", + "value": "" + }, + { + "name": "edgeMode", + "value": "" + }, + { + "name": "filterUnits", + "value": "" + }, + { + "name": "filterres", + "value": "" + }, + { + "name": "glyphRef", + "value": "" + }, + { + "name": "gradientTransform", + "value": "" + }, + { + "name": "gradientUnits", + "value": "" + }, + { + "name": "kernelMatrix", + "value": "" + }, + { + "name": "kernelUnitLength", + "value": "" + }, + { + "name": "keyPoints", + "value": "" + }, + { + "name": "keySplines", + "value": "" + }, + { + "name": "keyTimes", + "value": "" + }, + { + "name": "lengthAdjust", + "value": "" + }, + { + "name": "limitingConeAngle", + "value": "" + }, + { + "name": "markerHeight", + "value": "" + }, + { + "name": "markerUnits", + "value": "" + }, + { + "name": "markerWidth", + "value": "" + }, + { + "name": "maskContentUnits", + "value": "" + }, + { + "name": "maskUnits", + "value": "" + }, + { + "name": "numOctaves", + "value": "" + }, + { + "name": "pathLength", + "value": "" + }, + { + "name": "patternContentUnits", + "value": "" + }, + { + "name": "patternTransform", + "value": "" + }, + { + "name": "patternUnits", + "value": "" + }, + { + "name": "pointsAtX", + "value": "" + }, + { + "name": "pointsAtY", + "value": "" + }, + { + "name": "pointsAtZ", + "value": "" + }, + { + "name": "preserveAlpha", + "value": "" + }, + { + "name": "preserveAspectRatio", + "value": "" + }, + { + "name": "primitiveUnits", + "value": "" + }, + { + "name": "refX", + "value": "" + }, + { + "name": "refY", + "value": "" + }, + { + "name": "repeatCount", + "value": "" + }, + { + "name": "repeatDur", + "value": "" + }, + { + "name": "requiredExtensions", + "value": "" + }, + { + "name": "requiredFeatures", + "value": "" + }, + { + "name": "specularConstant", + "value": "" + }, + { + "name": "specularExponent", + "value": "" + }, + { + "name": "spreadMethod", + "value": "" + }, + { + "name": "startOffset", + "value": "" + }, + { + "name": "stdDeviation", + "value": "" + }, + { + "name": "stitchTiles", + "value": "" + }, + { + "name": "surfaceScale", + "value": "" + }, + { + "name": "systemLanguage", + "value": "" + }, + { + "name": "tableValues", + "value": "" + }, + { + "name": "targetX", + "value": "" + }, + { + "name": "targetY", + "value": "" + }, + { + "name": "textLength", + "value": "" + }, + { + "name": "viewBox", + "value": "" + }, + { + "name": "viewTarget", + "value": "" + }, + { + "name": "xChannelSelector", + "value": "" + }, + { + "name": "yChannelSelector", + "value": "" + }, + { + "name": "zoomAndPan", + "value": "" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg attributeName=\"\" attributeType=\"\" baseFrequency=\"\" baseProfile=\"\" calcMode=\"\" clipPathUnits=\"\" diffuseConstant=\"\" edgeMode=\"\" filterUnits=\"\" filterres=\"\" glyphRef=\"\" gradientTransform=\"\" gradientUnits=\"\" kernelMatrix=\"\" kernelUnitLength=\"\" keyPoints=\"\" keySplines=\"\" keyTimes=\"\" lengthAdjust=\"\" limitingConeAngle=\"\" markerHeight=\"\" markerUnits=\"\" markerWidth=\"\" maskContentUnits=\"\" maskUnits=\"\" numOctaves=\"\" pathLength=\"\" patternContentUnits=\"\" patternTransform=\"\" patternUnits=\"\" pointsAtX=\"\" pointsAtY=\"\" pointsAtZ=\"\" preserveAlpha=\"\" preserveAspectRatio=\"\" primitiveUnits=\"\" refX=\"\" refY=\"\" repeatCount=\"\" repeatDur=\"\" requiredExtensions=\"\" requiredFeatures=\"\" specularConstant=\"\" specularExponent=\"\" spreadMethod=\"\" startOffset=\"\" stdDeviation=\"\" stitchTiles=\"\" surfaceScale=\"\" systemLanguage=\"\" tableValues=\"\" targetX=\"\" targetY=\"\" textLength=\"\" viewBox=\"\" viewTarget=\"\" xChannelSelector=\"\" yChannelSelector=\"\" zoomAndPan=\"\"></svg></body></html>", + "noQuirksBodyHtml": "<svg attributeName=\"\" attributeType=\"\" baseFrequency=\"\" baseProfile=\"\" calcMode=\"\" clipPathUnits=\"\" diffuseConstant=\"\" edgeMode=\"\" filterUnits=\"\" filterres=\"\" glyphRef=\"\" gradientTransform=\"\" gradientUnits=\"\" kernelMatrix=\"\" kernelUnitLength=\"\" keyPoints=\"\" keySplines=\"\" keyTimes=\"\" lengthAdjust=\"\" limitingConeAngle=\"\" markerHeight=\"\" markerUnits=\"\" markerWidth=\"\" maskContentUnits=\"\" maskUnits=\"\" numOctaves=\"\" pathLength=\"\" patternContentUnits=\"\" patternTransform=\"\" patternUnits=\"\" pointsAtX=\"\" pointsAtY=\"\" pointsAtZ=\"\" preserveAlpha=\"\" preserveAspectRatio=\"\" primitiveUnits=\"\" refX=\"\" refY=\"\" repeatCount=\"\" repeatDur=\"\" requiredExtensions=\"\" requiredFeatures=\"\" specularConstant=\"\" specularExponent=\"\" spreadMethod=\"\" startOffset=\"\" stdDeviation=\"\" stitchTiles=\"\" surfaceScale=\"\" systemLanguage=\"\" tableValues=\"\" targetX=\"\" targetY=\"\" textLength=\"\" viewBox=\"\" viewTarget=\"\" xChannelSelector=\"\" yChannelSelector=\"\" zoomAndPan=\"\"></svg>" + } + }, + { + "data": "<!DOCTYPE html><body><math attributeName='' attributeType='' baseFrequency='' baseProfile='' calcMode='' clipPathUnits='' diffuseConstant='' edgeMode='' filterUnits='' glyphRef='' gradientTransform='' gradientUnits='' kernelMatrix='' kernelUnitLength='' keyPoints='' keySplines='' keyTimes='' lengthAdjust='' limitingConeAngle='' markerHeight='' markerUnits='' markerWidth='' maskContentUnits='' maskUnits='' numOctaves='' pathLength='' patternContentUnits='' patternTransform='' patternUnits='' pointsAtX='' pointsAtY='' pointsAtZ='' preserveAlpha='' preserveAspectRatio='' primitiveUnits='' refX='' refY='' repeatCount='' repeatDur='' requiredExtensions='' requiredFeatures='' specularConstant='' specularExponent='' spreadMethod='' startOffset='' stdDeviation='' stitchTiles='' surfaceScale='' systemLanguage='' tableValues='' targetX='' targetY='' textLength='' viewBox='' viewTarget='' xChannelSelector='' yChannelSelector='' zoomAndPan=''></math>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "attrs": [ + { + "name": "attributename", + "value": "" + }, + { + "name": "attributetype", + "value": "" + }, + { + "name": "basefrequency", + "value": "" + }, + { + "name": "baseprofile", + "value": "" + }, + { + "name": "calcmode", + "value": "" + }, + { + "name": "clippathunits", + "value": "" + }, + { + "name": "diffuseconstant", + "value": "" + }, + { + "name": "edgemode", + "value": "" + }, + { + "name": "filterunits", + "value": "" + }, + { + "name": "glyphref", + "value": "" + }, + { + "name": "gradienttransform", + "value": "" + }, + { + "name": "gradientunits", + "value": "" + }, + { + "name": "kernelmatrix", + "value": "" + }, + { + "name": "kernelunitlength", + "value": "" + }, + { + "name": "keypoints", + "value": "" + }, + { + "name": "keysplines", + "value": "" + }, + { + "name": "keytimes", + "value": "" + }, + { + "name": "lengthadjust", + "value": "" + }, + { + "name": "limitingconeangle", + "value": "" + }, + { + "name": "markerheight", + "value": "" + }, + { + "name": "markerunits", + "value": "" + }, + { + "name": "markerwidth", + "value": "" + }, + { + "name": "maskcontentunits", + "value": "" + }, + { + "name": "maskunits", + "value": "" + }, + { + "name": "numoctaves", + "value": "" + }, + { + "name": "pathlength", + "value": "" + }, + { + "name": "patterncontentunits", + "value": "" + }, + { + "name": "patterntransform", + "value": "" + }, + { + "name": "patternunits", + "value": "" + }, + { + "name": "pointsatx", + "value": "" + }, + { + "name": "pointsaty", + "value": "" + }, + { + "name": "pointsatz", + "value": "" + }, + { + "name": "preservealpha", + "value": "" + }, + { + "name": "preserveaspectratio", + "value": "" + }, + { + "name": "primitiveunits", + "value": "" + }, + { + "name": "refx", + "value": "" + }, + { + "name": "refy", + "value": "" + }, + { + "name": "repeatcount", + "value": "" + }, + { + "name": "repeatdur", + "value": "" + }, + { + "name": "requiredextensions", + "value": "" + }, + { + "name": "requiredfeatures", + "value": "" + }, + { + "name": "specularconstant", + "value": "" + }, + { + "name": "specularexponent", + "value": "" + }, + { + "name": "spreadmethod", + "value": "" + }, + { + "name": "startoffset", + "value": "" + }, + { + "name": "stddeviation", + "value": "" + }, + { + "name": "stitchtiles", + "value": "" + }, + { + "name": "surfacescale", + "value": "" + }, + { + "name": "systemlanguage", + "value": "" + }, + { + "name": "tablevalues", + "value": "" + }, + { + "name": "targetx", + "value": "" + }, + { + "name": "targety", + "value": "" + }, + { + "name": "textlength", + "value": "" + }, + { + "name": "viewbox", + "value": "" + }, + { + "name": "viewtarget", + "value": "" + }, + { + "name": "xchannelselector", + "value": "" + }, + { + "name": "ychannelselector", + "value": "" + }, + { + "name": "zoomandpan", + "value": "" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math attributename=\"\" attributetype=\"\" basefrequency=\"\" baseprofile=\"\" calcmode=\"\" clippathunits=\"\" diffuseconstant=\"\" edgemode=\"\" filterunits=\"\" glyphref=\"\" gradienttransform=\"\" gradientunits=\"\" kernelmatrix=\"\" kernelunitlength=\"\" keypoints=\"\" keysplines=\"\" keytimes=\"\" lengthadjust=\"\" limitingconeangle=\"\" markerheight=\"\" markerunits=\"\" markerwidth=\"\" maskcontentunits=\"\" maskunits=\"\" numoctaves=\"\" pathlength=\"\" patterncontentunits=\"\" patterntransform=\"\" patternunits=\"\" pointsatx=\"\" pointsaty=\"\" pointsatz=\"\" preservealpha=\"\" preserveaspectratio=\"\" primitiveunits=\"\" refx=\"\" refy=\"\" repeatcount=\"\" repeatdur=\"\" requiredextensions=\"\" requiredfeatures=\"\" specularconstant=\"\" specularexponent=\"\" spreadmethod=\"\" startoffset=\"\" stddeviation=\"\" stitchtiles=\"\" surfacescale=\"\" systemlanguage=\"\" tablevalues=\"\" targetx=\"\" targety=\"\" textlength=\"\" viewbox=\"\" viewtarget=\"\" xchannelselector=\"\" ychannelselector=\"\" zoomandpan=\"\"></math></body></html>", + "noQuirksBodyHtml": "<math attributename=\"\" attributetype=\"\" basefrequency=\"\" baseprofile=\"\" calcmode=\"\" clippathunits=\"\" diffuseconstant=\"\" edgemode=\"\" filterunits=\"\" glyphref=\"\" gradienttransform=\"\" gradientunits=\"\" kernelmatrix=\"\" kernelunitlength=\"\" keypoints=\"\" keysplines=\"\" keytimes=\"\" lengthadjust=\"\" limitingconeangle=\"\" markerheight=\"\" markerunits=\"\" markerwidth=\"\" maskcontentunits=\"\" maskunits=\"\" numoctaves=\"\" pathlength=\"\" patterncontentunits=\"\" patterntransform=\"\" patternunits=\"\" pointsatx=\"\" pointsaty=\"\" pointsatz=\"\" preservealpha=\"\" preserveaspectratio=\"\" primitiveunits=\"\" refx=\"\" refy=\"\" repeatcount=\"\" repeatdur=\"\" requiredextensions=\"\" requiredfeatures=\"\" specularconstant=\"\" specularexponent=\"\" spreadmethod=\"\" startoffset=\"\" stddeviation=\"\" stitchtiles=\"\" surfacescale=\"\" systemlanguage=\"\" tablevalues=\"\" targetx=\"\" targety=\"\" textlength=\"\" viewbox=\"\" viewtarget=\"\" xchannelselector=\"\" ychannelselector=\"\" zoomandpan=\"\"></math>" + } + }, + { + "data": "<!DOCTYPE html><body><svg><altGlyph /><altGlyphDef /><altGlyphItem /><animateColor /><animateMotion /><animateTransform /><clipPath /><feBlend /><feColorMatrix /><feComponentTransfer /><feComposite /><feConvolveMatrix /><feDiffuseLighting /><feDisplacementMap /><feDistantLight /><feFlood /><feFuncA /><feFuncB /><feFuncG /><feFuncR /><feGaussianBlur /><feImage /><feMerge /><feMergeNode /><feMorphology /><feOffset /><fePointLight /><feSpecularLighting /><feSpotLight /><feTile /><feTurbulence /><foreignObject /><glyphRef /><linearGradient /><radialGradient /><textPath /></svg>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg altGlyph": true, + "svg altGlyphDef": true, + "svg altGlyphItem": true, + "svg animateColor": true, + "svg animateMotion": true, + "svg animateTransform": true, + "svg clipPath": true, + "svg feBlend": true, + "svg feColorMatrix": true, + "svg feComponentTransfer": true, + "svg feComposite": true, + "svg feConvolveMatrix": true, + "svg feDiffuseLighting": true, + "svg feDisplacementMap": true, + "svg feDistantLight": true, + "svg feFlood": true, + "svg feFuncA": true, + "svg feFuncB": true, + "svg feFuncG": true, + "svg feFuncR": true, + "svg feGaussianBlur": true, + "svg feImage": true, + "svg feMerge": true, + "svg feMergeNode": true, + "svg feMorphology": true, + "svg feOffset": true, + "svg fePointLight": true, + "svg feSpecularLighting": true, + "svg feSpotLight": true, + "svg feTile": true, + "svg feTurbulence": true, + "svg foreignObject": true, + "svg glyphRef": true, + "svg linearGradient": true, + "svg radialGradient": true, + "svg textPath": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "altGlyph", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "altGlyphDef", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "altGlyphItem", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "animateColor", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "animateMotion", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "animateTransform", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "clipPath", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feBlend", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feColorMatrix", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feComponentTransfer", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feComposite", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feConvolveMatrix", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feDiffuseLighting", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feDisplacementMap", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feDistantLight", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feFlood", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feFuncA", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feFuncB", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feFuncG", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feFuncR", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feGaussianBlur", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feImage", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feMerge", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feMergeNode", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feMorphology", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feOffset", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "fePointLight", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feSpecularLighting", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feSpotLight", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feTile", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feTurbulence", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "glyphRef", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "linearGradient", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "radialGradient", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "textPath", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg><altGlyph></altGlyph><altGlyphDef></altGlyphDef><altGlyphItem></altGlyphItem><animateColor></animateColor><animateMotion></animateMotion><animateTransform></animateTransform><clipPath></clipPath><feBlend></feBlend><feColorMatrix></feColorMatrix><feComponentTransfer></feComponentTransfer><feComposite></feComposite><feConvolveMatrix></feConvolveMatrix><feDiffuseLighting></feDiffuseLighting><feDisplacementMap></feDisplacementMap><feDistantLight></feDistantLight><feFlood></feFlood><feFuncA></feFuncA><feFuncB></feFuncB><feFuncG></feFuncG><feFuncR></feFuncR><feGaussianBlur></feGaussianBlur><feImage></feImage><feMerge></feMerge><feMergeNode></feMergeNode><feMorphology></feMorphology><feOffset></feOffset><fePointLight></fePointLight><feSpecularLighting></feSpecularLighting><feSpotLight></feSpotLight><feTile></feTile><feTurbulence></feTurbulence><foreignObject></foreignObject><glyphRef></glyphRef><linearGradient></linearGradient><radialGradient></radialGradient><textPath></textPath></svg></body></html>", + "noQuirksBodyHtml": "<svg><altGlyph></altGlyph><altGlyphDef></altGlyphDef><altGlyphItem></altGlyphItem><animateColor></animateColor><animateMotion></animateMotion><animateTransform></animateTransform><clipPath></clipPath><feBlend></feBlend><feColorMatrix></feColorMatrix><feComponentTransfer></feComponentTransfer><feComposite></feComposite><feConvolveMatrix></feConvolveMatrix><feDiffuseLighting></feDiffuseLighting><feDisplacementMap></feDisplacementMap><feDistantLight></feDistantLight><feFlood></feFlood><feFuncA></feFuncA><feFuncB></feFuncB><feFuncG></feFuncG><feFuncR></feFuncR><feGaussianBlur></feGaussianBlur><feImage></feImage><feMerge></feMerge><feMergeNode></feMergeNode><feMorphology></feMorphology><feOffset></feOffset><fePointLight></fePointLight><feSpecularLighting></feSpecularLighting><feSpotLight></feSpotLight><feTile></feTile><feTurbulence></feTurbulence><foreignObject></foreignObject><glyphRef></glyphRef><linearGradient></linearGradient><radialGradient></radialGradient><textPath></textPath></svg>" + } + }, + { + "data": "<!DOCTYPE html><body><svg><altglyph /><altglyphdef /><altglyphitem /><animatecolor /><animatemotion /><animatetransform /><clippath /><feblend /><fecolormatrix /><fecomponenttransfer /><fecomposite /><feconvolvematrix /><fediffuselighting /><fedisplacementmap /><fedistantlight /><feflood /><fefunca /><fefuncb /><fefuncg /><fefuncr /><fegaussianblur /><feimage /><femerge /><femergenode /><femorphology /><feoffset /><fepointlight /><fespecularlighting /><fespotlight /><fetile /><feturbulence /><foreignobject /><glyphref /><lineargradient /><radialgradient /><textpath /></svg>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg altGlyph": true, + "svg altGlyphDef": true, + "svg altGlyphItem": true, + "svg animateColor": true, + "svg animateMotion": true, + "svg animateTransform": true, + "svg clipPath": true, + "svg feBlend": true, + "svg feColorMatrix": true, + "svg feComponentTransfer": true, + "svg feComposite": true, + "svg feConvolveMatrix": true, + "svg feDiffuseLighting": true, + "svg feDisplacementMap": true, + "svg feDistantLight": true, + "svg feFlood": true, + "svg feFuncA": true, + "svg feFuncB": true, + "svg feFuncG": true, + "svg feFuncR": true, + "svg feGaussianBlur": true, + "svg feImage": true, + "svg feMerge": true, + "svg feMergeNode": true, + "svg feMorphology": true, + "svg feOffset": true, + "svg fePointLight": true, + "svg feSpecularLighting": true, + "svg feSpotLight": true, + "svg feTile": true, + "svg feTurbulence": true, + "svg foreignObject": true, + "svg glyphRef": true, + "svg linearGradient": true, + "svg radialGradient": true, + "svg textPath": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "altGlyph", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "altGlyphDef", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "altGlyphItem", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "animateColor", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "animateMotion", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "animateTransform", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "clipPath", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feBlend", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feColorMatrix", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feComponentTransfer", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feComposite", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feConvolveMatrix", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feDiffuseLighting", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feDisplacementMap", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feDistantLight", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feFlood", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feFuncA", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feFuncB", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feFuncG", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feFuncR", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feGaussianBlur", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feImage", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feMerge", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feMergeNode", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feMorphology", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feOffset", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "fePointLight", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feSpecularLighting", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feSpotLight", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feTile", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feTurbulence", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "glyphRef", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "linearGradient", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "radialGradient", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "textPath", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg><altGlyph></altGlyph><altGlyphDef></altGlyphDef><altGlyphItem></altGlyphItem><animateColor></animateColor><animateMotion></animateMotion><animateTransform></animateTransform><clipPath></clipPath><feBlend></feBlend><feColorMatrix></feColorMatrix><feComponentTransfer></feComponentTransfer><feComposite></feComposite><feConvolveMatrix></feConvolveMatrix><feDiffuseLighting></feDiffuseLighting><feDisplacementMap></feDisplacementMap><feDistantLight></feDistantLight><feFlood></feFlood><feFuncA></feFuncA><feFuncB></feFuncB><feFuncG></feFuncG><feFuncR></feFuncR><feGaussianBlur></feGaussianBlur><feImage></feImage><feMerge></feMerge><feMergeNode></feMergeNode><feMorphology></feMorphology><feOffset></feOffset><fePointLight></fePointLight><feSpecularLighting></feSpecularLighting><feSpotLight></feSpotLight><feTile></feTile><feTurbulence></feTurbulence><foreignObject></foreignObject><glyphRef></glyphRef><linearGradient></linearGradient><radialGradient></radialGradient><textPath></textPath></svg></body></html>", + "noQuirksBodyHtml": "<svg><altGlyph></altGlyph><altGlyphDef></altGlyphDef><altGlyphItem></altGlyphItem><animateColor></animateColor><animateMotion></animateMotion><animateTransform></animateTransform><clipPath></clipPath><feBlend></feBlend><feColorMatrix></feColorMatrix><feComponentTransfer></feComponentTransfer><feComposite></feComposite><feConvolveMatrix></feConvolveMatrix><feDiffuseLighting></feDiffuseLighting><feDisplacementMap></feDisplacementMap><feDistantLight></feDistantLight><feFlood></feFlood><feFuncA></feFuncA><feFuncB></feFuncB><feFuncG></feFuncG><feFuncR></feFuncR><feGaussianBlur></feGaussianBlur><feImage></feImage><feMerge></feMerge><feMergeNode></feMergeNode><feMorphology></feMorphology><feOffset></feOffset><fePointLight></fePointLight><feSpecularLighting></feSpecularLighting><feSpotLight></feSpotLight><feTile></feTile><feTurbulence></feTurbulence><foreignObject></foreignObject><glyphRef></glyphRef><linearGradient></linearGradient><radialGradient></radialGradient><textPath></textPath></svg>" + } + }, + { + "data": "<!DOCTYPE html><BODY><SVG><ALTGLYPH /><ALTGLYPHDEF /><ALTGLYPHITEM /><ANIMATECOLOR /><ANIMATEMOTION /><ANIMATETRANSFORM /><CLIPPATH /><FEBLEND /><FECOLORMATRIX /><FECOMPONENTTRANSFER /><FECOMPOSITE /><FECONVOLVEMATRIX /><FEDIFFUSELIGHTING /><FEDISPLACEMENTMAP /><FEDISTANTLIGHT /><FEFLOOD /><FEFUNCA /><FEFUNCB /><FEFUNCG /><FEFUNCR /><FEGAUSSIANBLUR /><FEIMAGE /><FEMERGE /><FEMERGENODE /><FEMORPHOLOGY /><FEOFFSET /><FEPOINTLIGHT /><FESPECULARLIGHTING /><FESPOTLIGHT /><FETILE /><FETURBULENCE /><FOREIGNOBJECT /><GLYPHREF /><LINEARGRADIENT /><RADIALGRADIENT /><TEXTPATH /></SVG>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg altGlyph": true, + "svg altGlyphDef": true, + "svg altGlyphItem": true, + "svg animateColor": true, + "svg animateMotion": true, + "svg animateTransform": true, + "svg clipPath": true, + "svg feBlend": true, + "svg feColorMatrix": true, + "svg feComponentTransfer": true, + "svg feComposite": true, + "svg feConvolveMatrix": true, + "svg feDiffuseLighting": true, + "svg feDisplacementMap": true, + "svg feDistantLight": true, + "svg feFlood": true, + "svg feFuncA": true, + "svg feFuncB": true, + "svg feFuncG": true, + "svg feFuncR": true, + "svg feGaussianBlur": true, + "svg feImage": true, + "svg feMerge": true, + "svg feMergeNode": true, + "svg feMorphology": true, + "svg feOffset": true, + "svg fePointLight": true, + "svg feSpecularLighting": true, + "svg feSpotLight": true, + "svg feTile": true, + "svg feTurbulence": true, + "svg foreignObject": true, + "svg glyphRef": true, + "svg linearGradient": true, + "svg radialGradient": true, + "svg textPath": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "altGlyph", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "altGlyphDef", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "altGlyphItem", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "animateColor", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "animateMotion", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "animateTransform", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "clipPath", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feBlend", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feColorMatrix", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feComponentTransfer", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feComposite", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feConvolveMatrix", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feDiffuseLighting", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feDisplacementMap", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feDistantLight", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feFlood", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feFuncA", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feFuncB", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feFuncG", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feFuncR", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feGaussianBlur", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feImage", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feMerge", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feMergeNode", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feMorphology", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feOffset", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "fePointLight", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feSpecularLighting", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feSpotLight", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feTile", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "feTurbulence", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "glyphRef", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "linearGradient", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "radialGradient", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "textPath", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg><altGlyph></altGlyph><altGlyphDef></altGlyphDef><altGlyphItem></altGlyphItem><animateColor></animateColor><animateMotion></animateMotion><animateTransform></animateTransform><clipPath></clipPath><feBlend></feBlend><feColorMatrix></feColorMatrix><feComponentTransfer></feComponentTransfer><feComposite></feComposite><feConvolveMatrix></feConvolveMatrix><feDiffuseLighting></feDiffuseLighting><feDisplacementMap></feDisplacementMap><feDistantLight></feDistantLight><feFlood></feFlood><feFuncA></feFuncA><feFuncB></feFuncB><feFuncG></feFuncG><feFuncR></feFuncR><feGaussianBlur></feGaussianBlur><feImage></feImage><feMerge></feMerge><feMergeNode></feMergeNode><feMorphology></feMorphology><feOffset></feOffset><fePointLight></fePointLight><feSpecularLighting></feSpecularLighting><feSpotLight></feSpotLight><feTile></feTile><feTurbulence></feTurbulence><foreignObject></foreignObject><glyphRef></glyphRef><linearGradient></linearGradient><radialGradient></radialGradient><textPath></textPath></svg></body></html>", + "noQuirksBodyHtml": "<svg><altGlyph></altGlyph><altGlyphDef></altGlyphDef><altGlyphItem></altGlyphItem><animateColor></animateColor><animateMotion></animateMotion><animateTransform></animateTransform><clipPath></clipPath><feBlend></feBlend><feColorMatrix></feColorMatrix><feComponentTransfer></feComponentTransfer><feComposite></feComposite><feConvolveMatrix></feConvolveMatrix><feDiffuseLighting></feDiffuseLighting><feDisplacementMap></feDisplacementMap><feDistantLight></feDistantLight><feFlood></feFlood><feFuncA></feFuncA><feFuncB></feFuncB><feFuncG></feFuncG><feFuncR></feFuncR><feGaussianBlur></feGaussianBlur><feImage></feImage><feMerge></feMerge><feMergeNode></feMergeNode><feMorphology></feMorphology><feOffset></feOffset><fePointLight></fePointLight><feSpecularLighting></feSpecularLighting><feSpotLight></feSpotLight><feTile></feTile><feTurbulence></feTurbulence><foreignObject></foreignObject><glyphRef></glyphRef><linearGradient></linearGradient><radialGradient></radialGradient><textPath></textPath></svg>" + } + }, + { + "data": "<!DOCTYPE html><body><math><altGlyph /><altGlyphDef /><altGlyphItem /><animateColor /><animateMotion /><animateTransform /><clipPath /><feBlend /><feColorMatrix /><feComponentTransfer /><feComposite /><feConvolveMatrix /><feDiffuseLighting /><feDisplacementMap /><feDistantLight /><feFlood /><feFuncA /><feFuncB /><feFuncG /><feFuncR /><feGaussianBlur /><feImage /><feMerge /><feMergeNode /><feMorphology /><feOffset /><fePointLight /><feSpecularLighting /><feSpotLight /><feTile /><feTurbulence /><foreignObject /><glyphRef /><linearGradient /><radialGradient /><textPath /></math>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math altglyph": true, + "math altglyphdef": true, + "math altglyphitem": true, + "math animatecolor": true, + "math animatemotion": true, + "math animatetransform": true, + "math clippath": true, + "math feblend": true, + "math fecolormatrix": true, + "math fecomponenttransfer": true, + "math fecomposite": true, + "math feconvolvematrix": true, + "math fediffuselighting": true, + "math fedisplacementmap": true, + "math fedistantlight": true, + "math feflood": true, + "math fefunca": true, + "math fefuncb": true, + "math fefuncg": true, + "math fefuncr": true, + "math fegaussianblur": true, + "math feimage": true, + "math femerge": true, + "math femergenode": true, + "math femorphology": true, + "math feoffset": true, + "math fepointlight": true, + "math fespecularlighting": true, + "math fespotlight": true, + "math fetile": true, + "math feturbulence": true, + "math foreignobject": true, + "math glyphref": true, + "math lineargradient": true, + "math radialgradient": true, + "math textpath": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "altglyph", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "altglyphdef", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "altglyphitem", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "animatecolor", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "animatemotion", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "animatetransform", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "clippath", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "feblend", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "fecolormatrix", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "fecomponenttransfer", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "fecomposite", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "feconvolvematrix", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "fediffuselighting", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "fedisplacementmap", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "fedistantlight", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "feflood", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "fefunca", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "fefuncb", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "fefuncg", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "fefuncr", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "fegaussianblur", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "feimage", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "femerge", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "femergenode", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "femorphology", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "feoffset", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "fepointlight", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "fespecularlighting", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "fespotlight", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "fetile", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "feturbulence", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "foreignobject", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "glyphref", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "lineargradient", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "radialgradient", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "textpath", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math><altglyph></altglyph><altglyphdef></altglyphdef><altglyphitem></altglyphitem><animatecolor></animatecolor><animatemotion></animatemotion><animatetransform></animatetransform><clippath></clippath><feblend></feblend><fecolormatrix></fecolormatrix><fecomponenttransfer></fecomponenttransfer><fecomposite></fecomposite><feconvolvematrix></feconvolvematrix><fediffuselighting></fediffuselighting><fedisplacementmap></fedisplacementmap><fedistantlight></fedistantlight><feflood></feflood><fefunca></fefunca><fefuncb></fefuncb><fefuncg></fefuncg><fefuncr></fefuncr><fegaussianblur></fegaussianblur><feimage></feimage><femerge></femerge><femergenode></femergenode><femorphology></femorphology><feoffset></feoffset><fepointlight></fepointlight><fespecularlighting></fespecularlighting><fespotlight></fespotlight><fetile></fetile><feturbulence></feturbulence><foreignobject></foreignobject><glyphref></glyphref><lineargradient></lineargradient><radialgradient></radialgradient><textpath></textpath></math></body></html>", + "noQuirksBodyHtml": "<math><altglyph></altglyph><altglyphdef></altglyphdef><altglyphitem></altglyphitem><animatecolor></animatecolor><animatemotion></animatemotion><animatetransform></animatetransform><clippath></clippath><feblend></feblend><fecolormatrix></fecolormatrix><fecomponenttransfer></fecomponenttransfer><fecomposite></fecomposite><feconvolvematrix></feconvolvematrix><fediffuselighting></fediffuselighting><fedisplacementmap></fedisplacementmap><fedistantlight></fedistantlight><feflood></feflood><fefunca></fefunca><fefuncb></fefuncb><fefuncg></fefuncg><fefuncr></fefuncr><fegaussianblur></fegaussianblur><feimage></feimage><femerge></femerge><femergenode></femergenode><femorphology></femorphology><feoffset></feoffset><fepointlight></fepointlight><fespecularlighting></fespecularlighting><fespotlight></fespotlight><fetile></fetile><feturbulence></feturbulence><foreignobject></foreignobject><glyphref></glyphref><lineargradient></lineargradient><radialgradient></radialgradient><textpath></textpath></math>" + } + }, + { + "data": "<!DOCTYPE html><body><svg><solidColor /></svg>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg solidcolor": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "solidcolor", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg><solidcolor></solidcolor></svg></body></html>", + "noQuirksBodyHtml": "<svg><solidcolor></solidcolor></svg>" + } + } + ], + "tests12.dat": [ + { + "data": "<!DOCTYPE html><body><p>foo<math><mtext><i>baz</i></mtext><annotation-xml><svg><desc><b>eggs</b></desc><g><foreignObject><P>spam<TABLE><tr><td><img></td></table></foreignObject></g><g>quux</g></svg></annotation-xml></math>bar", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "math math": true, + "math mtext": true, + "i": true, + "math annotation-xml": true, + "svg svg": true, + "svg desc": true, + "b": true, + "svg g": true, + "svg foreignObject": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "img": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "text": "foo" + }, + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mtext", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "i", + "children": [ + { + "text": "baz" + } + ] + } + ] + }, + { + "tag": "annotation-xml", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "desc", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "eggs" + } + ] + } + ] + }, + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "p", + "children": [ + { + "text": "spam" + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "img" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "quux" + } + ] + } + ] + } + ] + } + ] + }, + { + "text": "bar" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p>foo<math><mtext><i>baz</i></mtext><annotation-xml><svg><desc><b>eggs</b></desc><g><foreignObject><p>spam</p><table><tbody><tr><td><img></td></tr></tbody></table></foreignObject></g><g>quux</g></svg></annotation-xml></math>bar</p></body></html>", + "noQuirksBodyHtml": "<p>foo<math><mtext><i>baz</i></mtext><annotation-xml><svg><desc><b>eggs</b></desc><g><foreignObject><p>spam</p><table><tbody><tr><td><img></td></tr></tbody></table></foreignObject></g><g>quux</g></svg></annotation-xml></math>bar</p>" + } + }, + { + "data": "<!DOCTYPE html><body>foo<math><mtext><i>baz</i></mtext><annotation-xml><svg><desc><b>eggs</b></desc><g><foreignObject><P>spam<TABLE><tr><td><img></td></table></foreignObject></g><g>quux</g></svg></annotation-xml></math>bar", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mtext": true, + "i": true, + "math annotation-xml": true, + "svg svg": true, + "svg desc": true, + "b": true, + "svg g": true, + "svg foreignObject": true, + "p": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "img": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "foo" + }, + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mtext", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "i", + "children": [ + { + "text": "baz" + } + ] + } + ] + }, + { + "tag": "annotation-xml", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "desc", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "eggs" + } + ] + } + ] + }, + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "p", + "children": [ + { + "text": "spam" + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "img" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "g", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "quux" + } + ] + } + ] + } + ] + } + ] + }, + { + "text": "bar" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>foo<math><mtext><i>baz</i></mtext><annotation-xml><svg><desc><b>eggs</b></desc><g><foreignObject><p>spam</p><table><tbody><tr><td><img></td></tr></tbody></table></foreignObject></g><g>quux</g></svg></annotation-xml></math>bar</body></html>", + "noQuirksBodyHtml": "foo<math><mtext><i>baz</i></mtext><annotation-xml><svg><desc><b>eggs</b></desc><g><foreignObject><p>spam</p><table><tbody><tr><td><img></td></tr></tbody></table></foreignObject></g><g>quux</g></svg></annotation-xml></math>bar" + } + } + ], + "tests14.dat": [ + { + "data": "<!DOCTYPE html><html><body><xyz:abc></xyz:abc>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "xyz:abc": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "xyz:abc" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><xyz:abc></xyz:abc></body></html>", + "noQuirksBodyHtml": "<xyz:abc></xyz:abc>" + } + }, + { + "data": "<!DOCTYPE html><html><body><xyz:abc></xyz:abc><span></span>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "xyz:abc": true, + "span": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "xyz:abc" + }, + { + "tag": "span" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><xyz:abc></xyz:abc><span></span></body></html>", + "noQuirksBodyHtml": "<xyz:abc></xyz:abc><span></span>" + } + }, + { + "data": "<!DOCTYPE html><html><html abc:def=gh><xyz:abc></xyz:abc>", + "errors": [ + "(1,38): non-html-root" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "xyz:abc": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "attrs": [ + { + "name": "abc:def", + "value": "gh" + } + ], + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "xyz:abc" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html abc:def=\"gh\"><head></head><body><xyz:abc></xyz:abc></body></html>", + "noQuirksBodyHtml": "<xyz:abc></xyz:abc>" + } + }, + { + "data": "<!DOCTYPE html><html xml:lang=bar><html xml:lang=foo>", + "errors": [ + "(1,53): non-html-root" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "attrs": [ + { + "name": "xml:lang", + "value": "bar" + } + ], + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html xml:lang=\"bar\"><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<!DOCTYPE html><html 123=456>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "attrs": [ + { + "name": "123", + "value": "456" + } + ], + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html 123=\"456\"><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<!DOCTYPE html><html 123=456><html 789=012>", + "errors": [ + "(1,43): non-html-root" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "attrs": [ + { + "name": "123", + "value": "456" + }, + { + "name": "789", + "value": "012" + } + ], + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html 123=\"456\" 789=\"012\"><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<!DOCTYPE html><html><body 789=012>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "attrs": [ + { + "name": "789", + "value": "012" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body 789=\"012\"></body></html>", + "noQuirksBodyHtml": "" + } + } + ], + "tests15.dat": [ + { + "data": "<!DOCTYPE html><p><b><i><u></p> <p>X", + "errors": [ + "(1,31): unexpected-end-tag", + "(1,36): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "b": true, + "i": true, + "u": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "i", + "children": [ + { + "tag": "u" + } + ] + } + ] + } + ] + }, + { + "tag": "b", + "children": [ + { + "tag": "i", + "children": [ + { + "tag": "u", + "children": [ + { + "text": " " + }, + { + "tag": "p", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><b><i><u></u></i></b></p><b><i><u> <p>X</p></u></i></b></body></html>", + "noQuirksBodyHtml": "<p><b><i><u></u></i></b></p><b><i><u> <p>X</p></u></i></b>" + } + }, + { + "data": "<p><b><i><u></p>\n<p>X", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,16): unexpected-end-tag", + "(2,4): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "b": true, + "i": true, + "u": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "i", + "children": [ + { + "tag": "u" + } + ] + } + ] + } + ] + }, + { + "tag": "b", + "children": [ + { + "tag": "i", + "children": [ + { + "tag": "u", + "children": [ + { + "text": "\n" + }, + { + "tag": "p", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p><b><i><u></u></i></b></p><b><i><u>\n<p>X</p></u></i></b></body></html>", + "noQuirksBodyHtml": "<p><b><i><u></u></i></b></p><b><i><u>\n<p>X</p></u></i></b>" + } + }, + { + "data": "<!doctype html></html> <head>", + "errors": [ + "(1,29): expected-eof-but-got-start-tag", + "(1,29): unexpected-start-tag-ignored" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": " " + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body> </body></html>", + "noQuirksBodyHtml": " " + } + }, + { + "data": "<!doctype html></body><meta>", + "errors": [ + "(1,28): unexpected-start-tag-after-body" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "meta": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "meta" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><meta></body></html>", + "noQuirksBodyHtml": "<meta>" + } + }, + { + "data": "<html></html><!-- foo -->", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + }, + { + "comment": " foo " + } + ], + "html": "<html><head></head><body></body></html><!-- foo -->", + "noQuirksBodyHtml": "<!-- foo -->" + } + }, + { + "data": "<!doctype html></body><title>X</title>", + "errors": [ + "(1,29): unexpected-start-tag-after-body" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "title": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><title>X</title></body></html>", + "noQuirksBodyHtml": "<title>X</title>" + } + }, + { + "data": "<!doctype html><table> X<meta></table>", + "errors": [ + "(1,23): foster-parenting-character", + "(1,24): foster-parenting-character", + "(1,30): foster-parenting-start-character" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "meta": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": " X" + }, + { + "tag": "meta" + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body> X<meta><table></table></body></html>", + "noQuirksBodyHtml": " X<meta><table></table>" + } + }, + { + "data": "<!doctype html><table> x</table>", + "errors": [ + "(1,23): foster-parenting-character", + "(1,24): foster-parenting-character" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": " x" + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body> x<table></table></body></html>", + "noQuirksBodyHtml": " x<table></table>" + } + }, + { + "data": "<!doctype html><table> x </table>", + "errors": [ + "(1,23): foster-parenting-character", + "(1,24): foster-parenting-character", + "(1,25): foster-parenting-character" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": " x " + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body> x <table></table></body></html>", + "noQuirksBodyHtml": " x <table></table>" + } + }, + { + "data": "<!doctype html><table><tr> x</table>", + "errors": [ + "(1,27): foster-parenting-character", + "(1,28): foster-parenting-character" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": " x" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body> x<table><tbody><tr></tr></tbody></table></body></html>", + "noQuirksBodyHtml": " x<table><tbody><tr></tr></tbody></table>" + } + }, + { + "data": "<!doctype html><table>X<style> <tr>x </style> </table>", + "errors": [ + "(1,23): foster-parenting-character" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "style": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "X" + }, + { + "tag": "table", + "children": [ + { + "tag": "style", + "children": [ + { + "text": " <tr>x ", + "no_escape": true + } + ] + }, + { + "text": " " + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>X<table><style> <tr>x </style> </table></body></html>", + "noQuirksBodyHtml": "X<table><style> <tr>x </style> </table>" + } + }, + { + "data": "<!doctype html><div><table><a>foo</a> <tr><td>bar</td> </tr></table></div>", + "errors": [ + "(1,30): foster-parenting-start-tag", + "(1,31): foster-parenting-character", + "(1,32): foster-parenting-character", + "(1,33): foster-parenting-character", + "(1,37): foster-parenting-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "a": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "a", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "table", + "children": [ + { + "text": " " + }, + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "text": "bar" + } + ] + }, + { + "text": " " + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><div><a>foo</a><table> <tbody><tr><td>bar</td> </tr></tbody></table></div></body></html>", + "noQuirksBodyHtml": "<div><a>foo</a><table> <tbody><tr><td>bar</td> </tr></tbody></table></div>" + } + }, + { + "data": "<frame></frame></frame><frameset><frame><frameset><frame></frameset><noframes></frameset><noframes>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,7): unexpected-start-tag-ignored", + "(1,15): unexpected-end-tag", + "(1,23): unexpected-end-tag", + "(1,33): unexpected-start-tag", + "(1,99): expected-named-closing-tag-but-got-eof", + "(1,99): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true, + "frame": true, + "noframes": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset", + "children": [ + { + "tag": "frame" + }, + { + "tag": "frameset", + "children": [ + { + "tag": "frame" + } + ] + }, + { + "tag": "noframes", + "children": [ + { + "text": "</frameset><noframes>", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><frameset><frame><frameset><frame></frameset><noframes></frameset><noframes></noframes></frameset></html>", + "noQuirksBodyHtml": "<noframes></frameset><noframes></noframes>" + } + }, + { + "data": "<!DOCTYPE html><object></html>", + "errors": [ + "(1,30): expected-body-in-scope", + "(1,30): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "object": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "object" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><object></object></body></html>", + "noQuirksBodyHtml": "<object></object>" + } + } + ], + "tests16.dat": [ + { + "data": "<!doctype html><script>", + "errors": [ + "(1,23): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script" + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script></script></head><body></body></html>", + "noQuirksBodyHtml": "<script></script>" + } + }, + { + "data": "<!doctype html><script>a", + "errors": [ + "(1,24): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "a", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script>a</script></head><body></body></html>", + "noQuirksBodyHtml": "<script>a</script>" + } + }, + { + "data": "<!doctype html><script><", + "errors": [ + "(1,24): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><</script>" + } + }, + { + "data": "<!doctype html><script></", + "errors": [ + "(1,25): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script></</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></</script>" + } + }, + { + "data": "<!doctype html><script></S", + "errors": [ + "(1,26): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</S", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script></S</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></S</script>" + } + }, + { + "data": "<!doctype html><script></SC", + "errors": [ + "(1,27): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</SC", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script></SC</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></SC</script>" + } + }, + { + "data": "<!doctype html><script></SCR", + "errors": [ + "(1,28): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</SCR", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script></SCR</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></SCR</script>" + } + }, + { + "data": "<!doctype html><script></SCRI", + "errors": [ + "(1,29): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</SCRI", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script></SCRI</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></SCRI</script>" + } + }, + { + "data": "<!doctype html><script></SCRIP", + "errors": [ + "(1,30): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</SCRIP", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script></SCRIP</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></SCRIP</script>" + } + }, + { + "data": "<!doctype html><script></SCRIPT", + "errors": [ + "(1,31): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</SCRIPT", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script></SCRIPT</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></SCRIPT</script>" + } + }, + { + "data": "<!doctype html><script></SCRIPT ", + "errors": [ + "(1,32): expected-attribute-name-but-got-eof", + "(1,32): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script" + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script></script></head><body></body></html>", + "noQuirksBodyHtml": "<script></script>" + } + }, + { + "data": "<!doctype html><script></s", + "errors": [ + "(1,26): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</s", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script></s</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></s</script>" + } + }, + { + "data": "<!doctype html><script></sc", + "errors": [ + "(1,27): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</sc", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script></sc</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></sc</script>" + } + }, + { + "data": "<!doctype html><script></scr", + "errors": [ + "(1,28): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</scr", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script></scr</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></scr</script>" + } + }, + { + "data": "<!doctype html><script></scri", + "errors": [ + "(1,29): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</scri", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script></scri</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></scri</script>" + } + }, + { + "data": "<!doctype html><script></scrip", + "errors": [ + "(1,30): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</scrip", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script></scrip</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></scrip</script>" + } + }, + { + "data": "<!doctype html><script></script", + "errors": [ + "(1,31): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</script", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script></script</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></script</script>" + } + }, + { + "data": "<!doctype html><script></script ", + "errors": [ + "(1,32): expected-attribute-name-but-got-eof", + "(1,32): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script" + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script></script></head><body></body></html>", + "noQuirksBodyHtml": "<script></script>" + } + }, + { + "data": "<!doctype html><script><!", + "errors": [ + "(1,25): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!</script>" + } + }, + { + "data": "<!doctype html><script><!a", + "errors": [ + "(1,26): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!a", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!a</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!a</script>" + } + }, + { + "data": "<!doctype html><script><!-", + "errors": [ + "(1,26): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!-", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!-</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!-</script>" + } + }, + { + "data": "<!doctype html><script><!-a", + "errors": [ + "(1,27): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!-a", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!-a</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!-a</script>" + } + }, + { + "data": "<!doctype html><script><!--", + "errors": [ + "(1,27): expected-named-closing-tag-but-got-eof", + "(1,27): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--</script>" + } + }, + { + "data": "<!doctype html><script><!--a", + "errors": [ + "(1,28): expected-named-closing-tag-but-got-eof", + "(1,28): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--a", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--a</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--a</script>" + } + }, + { + "data": "<!doctype html><script><!--<", + "errors": [ + "(1,28): expected-named-closing-tag-but-got-eof", + "(1,28): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<</script>" + } + }, + { + "data": "<!doctype html><script><!--<a", + "errors": [ + "(1,29): expected-named-closing-tag-but-got-eof", + "(1,29): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<a", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<a</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<a</script>" + } + }, + { + "data": "<!doctype html><script><!--</", + "errors": [ + "(1,29): expected-named-closing-tag-but-got-eof", + "(1,29): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--</", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--</</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--</</script>" + } + }, + { + "data": "<!doctype html><script><!--</script", + "errors": [ + "(1,35): expected-named-closing-tag-but-got-eof", + "(1,35): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--</script", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--</script</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--</script</script>" + } + }, + { + "data": "<!doctype html><script><!--</script ", + "errors": [ + "(1,36): expected-attribute-name-but-got-eof", + "(1,36): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--</script>" + } + }, + { + "data": "<!doctype html><script><!--<s", + "errors": [ + "(1,29): expected-named-closing-tag-but-got-eof", + "(1,29): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<s", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<s</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<s</script>" + } + }, + { + "data": "<!doctype html><script><!--<script", + "errors": [ + "(1,34): expected-named-closing-tag-but-got-eof", + "(1,34): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script</script>" + } + }, + { + "data": "<!doctype html><script><!--<script ", + "errors": [ + "(1,35): eof-in-script-in-script", + "(1,35): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script ", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script </script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script>" + } + }, + { + "data": "<!doctype html><script><!--<script <", + "errors": [ + "(1,36): eof-in-script-in-script", + "(1,36): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script <", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script <</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script <</script>" + } + }, + { + "data": "<!doctype html><script><!--<script <a", + "errors": [ + "(1,37): eof-in-script-in-script", + "(1,37): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script <a", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script <a</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script <a</script>" + } + }, + { + "data": "<!doctype html><script><!--<script </", + "errors": [ + "(1,37): eof-in-script-in-script", + "(1,37): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script </</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </</script>" + } + }, + { + "data": "<!doctype html><script><!--<script </s", + "errors": [ + "(1,38): eof-in-script-in-script", + "(1,38): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </s", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script </s</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </s</script>" + } + }, + { + "data": "<!doctype html><script><!--<script </script", + "errors": [ + "(1,43): eof-in-script-in-script", + "(1,43): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script </script</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script</script>" + } + }, + { + "data": "<!doctype html><script><!--<script </scripta", + "errors": [ + "(1,44): eof-in-script-in-script", + "(1,44): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </scripta", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script </scripta</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </scripta</script>" + } + }, + { + "data": "<!doctype html><script><!--<script </script ", + "errors": [ + "(1,44): expected-named-closing-tag-but-got-eof", + "(1,44): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script ", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script </script </script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script </script>" + } + }, + { + "data": "<!doctype html><script><!--<script </script>", + "errors": [ + "(1,44): expected-named-closing-tag-but-got-eof", + "(1,44): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script </script></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script></script>" + } + }, + { + "data": "<!doctype html><script><!--<script </script/", + "errors": [ + "(1,44): expected-named-closing-tag-but-got-eof", + "(1,44): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script/", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script </script/</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script/</script>" + } + }, + { + "data": "<!doctype html><script><!--<script </script <", + "errors": [ + "(1,45): expected-named-closing-tag-but-got-eof", + "(1,45): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script <", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script </script <</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script <</script>" + } + }, + { + "data": "<!doctype html><script><!--<script </script <a", + "errors": [ + "(1,46): expected-named-closing-tag-but-got-eof", + "(1,46): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script <a", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script </script <a</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script <a</script>" + } + }, + { + "data": "<!doctype html><script><!--<script </script </", + "errors": [ + "(1,46): expected-named-closing-tag-but-got-eof", + "(1,46): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script </", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script </script </</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script </</script>" + } + }, + { + "data": "<!doctype html><script><!--<script </script </script", + "errors": [ + "(1,52): expected-named-closing-tag-but-got-eof", + "(1,52): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script </script", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script </script </script</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script </script</script>" + } + }, + { + "data": "<!doctype html><script><!--<script </script </script ", + "errors": [ + "(1,53): expected-attribute-name-but-got-eof", + "(1,53): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script ", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script </script </script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script </script>" + } + }, + { + "data": "<!doctype html><script><!--<script </script </script/", + "errors": [ + "(1,53): unexpected-EOF-after-solidus-in-tag", + "(1,53): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script ", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script </script </script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script </script>" + } + }, + { + "data": "<!doctype html><script><!--<script </script </script>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script ", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script </script </script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script </script>" + } + }, + { + "data": "<!doctype html><script><!--<script -", + "errors": [ + "(1,36): eof-in-script-in-script", + "(1,36): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script -", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script -</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script -</script>" + } + }, + { + "data": "<!doctype html><script><!--<script -a", + "errors": [ + "(1,37): eof-in-script-in-script", + "(1,37): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script -a", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script -a</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script -a</script>" + } + }, + { + "data": "<!doctype html><script><!--<script -<", + "errors": [ + "(1,37): eof-in-script-in-script", + "(1,37): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script -<", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script -<</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script -<</script>" + } + }, + { + "data": "<!doctype html><script><!--<script --", + "errors": [ + "(1,37): eof-in-script-in-script", + "(1,37): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script --", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script --</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --</script>" + } + }, + { + "data": "<!doctype html><script><!--<script --a", + "errors": [ + "(1,38): eof-in-script-in-script", + "(1,38): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script --a", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script --a</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --a</script>" + } + }, + { + "data": "<!doctype html><script><!--<script --<", + "errors": [ + "(1,38): eof-in-script-in-script", + "(1,38): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script --<", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script --<</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --<</script>" + } + }, + { + "data": "<!doctype html><script><!--<script -->", + "errors": [ + "(1,38): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script -->", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script --></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --></script>" + } + }, + { + "data": "<!doctype html><script><!--<script --><", + "errors": [ + "(1,39): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script --><", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script --><</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --><</script>" + } + }, + { + "data": "<!doctype html><script><!--<script --></", + "errors": [ + "(1,40): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script --></", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script --></</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --></</script>" + } + }, + { + "data": "<!doctype html><script><!--<script --></script", + "errors": [ + "(1,46): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script --></script", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script --></script</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --></script</script>" + } + }, + { + "data": "<!doctype html><script><!--<script --></script ", + "errors": [ + "(1,47): expected-attribute-name-but-got-eof", + "(1,47): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script -->", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script --></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --></script>" + } + }, + { + "data": "<!doctype html><script><!--<script --></script/", + "errors": [ + "(1,47): unexpected-EOF-after-solidus-in-tag", + "(1,47): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script -->", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script --></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --></script>" + } + }, + { + "data": "<!doctype html><script><!--<script --></script>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script -->", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script --></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --></script>" + } + }, + { + "data": "<!doctype html><script><!--<script><\\/script>--></script>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script><\\/script>-->", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script><\\/script>--></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script><\\/script>--></script>" + } + }, + { + "data": "<!doctype html><script><!--<script></scr'+'ipt>--></script>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script></scr'+'ipt>-->", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script></scr'+'ipt>--></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script></scr'+'ipt>--></script>" + } + }, + { + "data": "<!doctype html><script><!--<script></script><script></script></script>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script></script><script></script>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script></script><script></script></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script></script><script></script></script>" + } + }, + { + "data": "<!doctype html><script><!--<script></script><script></script>--><!--</script>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script></script><script></script>--><!--", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script></script><script></script>--><!--</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script></script><script></script>--><!--</script>" + } + }, + { + "data": "<!doctype html><script><!--<script></script><script></script>-- ></script>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script></script><script></script>-- >", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script></script><script></script>-- ></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script></script><script></script>-- ></script>" + } + }, + { + "data": "<!doctype html><script><!--<script></script><script></script>- -></script>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script></script><script></script>- ->", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script></script><script></script>- -></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script></script><script></script>- -></script>" + } + }, + { + "data": "<!doctype html><script><!--<script></script><script></script>- - ></script>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script></script><script></script>- - >", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script></script><script></script>- - ></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script></script><script></script>- - ></script>" + } + }, + { + "data": "<!doctype html><script><!--<script></script><script></script>-></script>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script></script><script></script>->", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script></script><script></script>-></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script></script><script></script>-></script>" + } + }, + { + "data": "<!doctype html><script><!--<script>--!></script>X", + "errors": [ + "(1,49): expected-named-closing-tag-but-got-eof", + "(1,49): unexpected-EOF-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script>--!></script>X", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script>--!></script>X</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script>--!></script>X</script>" + } + }, + { + "data": "<!doctype html><script><!--<scr'+'ipt></script>--></script>", + "errors": [ + "(1,59): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<scr'+'ipt>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<scr'+'ipt></script></head><body>--&gt;</body></html>", + "noQuirksBodyHtml": "<script><!--<scr'+'ipt></script>--&gt;" + } + }, + { + "data": "<!doctype html><script><!--<script></scr'+'ipt></script>X", + "errors": [ + "(1,57): expected-named-closing-tag-but-got-eof", + "(1,57): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script></scr'+'ipt></script>X", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script><!--<script></scr'+'ipt></script>X</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script></scr'+'ipt></script>X</script>" + } + }, + { + "data": "<!doctype html><style><!--<style></style>--></style>", + "errors": [ + "(1,52): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "doctype": true, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "<!--<style>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><style><!--<style></style></head><body>--&gt;</body></html>", + "noQuirksBodyHtml": "<style><!--<style></style>--&gt;" + } + }, + { + "data": "<!doctype html><style><!--</style>X", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "<!--", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><style><!--</style></head><body>X</body></html>", + "noQuirksBodyHtml": "<style><!--</style>X" + } + }, + { + "data": "<!doctype html><style><!--...</style>...--></style>", + "errors": [ + "(1,51): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "doctype": true, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "<!--...", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "...-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><style><!--...</style></head><body>...--&gt;</body></html>", + "noQuirksBodyHtml": "<style><!--...</style>...--&gt;" + } + }, + { + "data": "<!doctype html><style><!--<br><html xmlns:v=\"urn:schemas-microsoft-com:vml\"><!--[if !mso]><style></style>X", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "<!--<br><html xmlns:v=\"urn:schemas-microsoft-com:vml\"><!--[if !mso]><style>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><style><!--<br><html xmlns:v=\"urn:schemas-microsoft-com:vml\"><!--[if !mso]><style></style></head><body>X</body></html>", + "noQuirksBodyHtml": "<style><!--<br><html xmlns:v=\"urn:schemas-microsoft-com:vml\"><!--[if !mso]><style></style>X" + } + }, + { + "data": "<!doctype html><style><!--...<style><!--...--!></style>--></style>", + "errors": [ + "(1,66): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "doctype": true, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "<!--...<style><!--...--!>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><style><!--...<style><!--...--!></style></head><body>--&gt;</body></html>", + "noQuirksBodyHtml": "<style><!--...<style><!--...--!></style>--&gt;" + } + }, + { + "data": "<!doctype html><style><!--...</style><!-- --><style>@import ...</style>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "doctype": true, + "no_escape": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "<!--...", + "no_escape": true + } + ] + }, + { + "comment": " " + }, + { + "tag": "style", + "children": [ + { + "text": "@import ...", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><style><!--...</style><!-- --><style>@import ...</style></head><body></body></html>", + "noQuirksBodyHtml": "<style><!--...</style><!-- --><style>@import ...</style>" + } + }, + { + "data": "<!doctype html><style>...<style><!--...</style><!-- --></style>", + "errors": [ + "(1,63): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "doctype": true, + "no_escape": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "...<style><!--...", + "no_escape": true + } + ] + }, + { + "comment": " " + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><style>...<style><!--...</style><!-- --></head><body></body></html>", + "noQuirksBodyHtml": "<style>...<style><!--...</style><!-- -->" + } + }, + { + "data": "<!doctype html><style>...<!--[if IE]><style>...</style>X", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "...<!--[if IE]><style>...", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><style>...<!--[if IE]><style>...</style></head><body>X</body></html>", + "noQuirksBodyHtml": "<style>...<!--[if IE]><style>...</style>X" + } + }, + { + "data": "<!doctype html><title><!--<title></title>--></title>", + "errors": [ + "(1,52): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "body": true + }, + "doctype": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "<!--<title>", + "escaped": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><title>&lt;!--&lt;title&gt;</title></head><body>--&gt;</body></html>", + "noQuirksBodyHtml": "<title>&lt;!--&lt;title&gt;</title>--&gt;" + } + }, + { + "data": "<!doctype html><title>&lt;/title></title>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "body": true + }, + "doctype": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "</title>", + "escaped": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><title>&lt;/title&gt;</title></head><body></body></html>", + "noQuirksBodyHtml": "<title>&lt;/title&gt;</title>" + } + }, + { + "data": "<!doctype html><title>foo/title><link></head><body>X", + "errors": [ + "(1,52): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "body": true + }, + "doctype": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "foo/title><link></head><body>X", + "escaped": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><title>foo/title&gt;&lt;link&gt;&lt;/head&gt;&lt;body&gt;X</title></head><body></body></html>", + "noQuirksBodyHtml": "<title>foo/title&gt;&lt;link&gt;&lt;/head&gt;&lt;body&gt;X</title>" + } + }, + { + "data": "<!doctype html><noscript><!--<noscript></noscript>--></noscript>", + "errors": [ + "(1,64): unexpected-end-tag" + ], + "script": "on", + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "noscript": true, + "body": true + }, + "doctype": true, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "noscript", + "children": [ + { + "text": "<!--<noscript>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><noscript><!--<noscript></noscript></head><body>--&gt;</body></html>", + "noQuirksBodyHtml": "<noscript>&lt;!--&lt;noscript&gt;</noscript>--&gt;" + } + }, + { + "data": "<!doctype html><noscript><!--<noscript></noscript>--></noscript>", + "errors": [], + "script": "off", + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "noscript": true, + "body": true + }, + "doctype": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "noscript", + "children": [ + { + "comment": "<noscript></noscript>" + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><noscript><!--<noscript></noscript>--></noscript></head><body></body></html>", + "noQuirksBodyHtml": "<noscript>&lt;!--&lt;noscript&gt;</noscript>--&gt;" + } + }, + { + "data": "<!doctype html><noscript><!--</noscript>X<noscript>--></noscript>", + "errors": [], + "script": "on", + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "noscript": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "noscript", + "children": [ + { + "text": "<!--", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "X" + }, + { + "tag": "noscript", + "children": [ + { + "text": "-->", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><noscript><!--</noscript></head><body>X<noscript>--></noscript></body></html>", + "noQuirksBodyHtml": "<noscript>&lt;!--</noscript>X<noscript>--&gt;</noscript>" + } + }, + { + "data": "<!doctype html><noscript><!--</noscript>X<noscript>--></noscript>", + "errors": [], + "script": "off", + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "noscript": true, + "body": true + }, + "doctype": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "noscript", + "children": [ + { + "comment": "</noscript>X<noscript>" + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><noscript><!--</noscript>X<noscript>--></noscript></head><body></body></html>", + "noQuirksBodyHtml": "<noscript>&lt;!--</noscript>X<noscript>--&gt;</noscript>" + } + }, + { + "data": "<!doctype html><noscript><iframe></noscript>X", + "errors": [], + "script": "on", + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "noscript": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "noscript", + "children": [ + { + "text": "<iframe>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><noscript><iframe></noscript></head><body>X</body></html>", + "noQuirksBodyHtml": "<noscript>&lt;iframe&gt;</noscript>X" + } + }, + { + "data": "<!doctype html><noscript><iframe></noscript>X", + "errors": [ + " * (1,34) unexpected token in head noscript", + " * (1,46) unexpected EOF" + ], + "script": "off", + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "noscript": true, + "body": true, + "iframe": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "noscript" + } + ] + }, + { + "tag": "body", + "children": [ + { + "tag": "iframe", + "children": [ + { + "text": "</noscript>X", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><noscript></noscript></head><body><iframe></noscript>X</iframe></body></html>", + "noQuirksBodyHtml": "<noscript>&lt;iframe&gt;</noscript>X" + } + }, + { + "data": "<!doctype html><noframes><!--<noframes></noframes>--></noframes>", + "errors": [ + "(1,64): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "noframes": true, + "body": true + }, + "doctype": true, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "noframes", + "children": [ + { + "text": "<!--<noframes>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><noframes><!--<noframes></noframes></head><body>--&gt;</body></html>", + "noQuirksBodyHtml": "<noframes><!--<noframes></noframes>--&gt;" + } + }, + { + "data": "<!doctype html><noframes><body><script><!--...</script></body></noframes></html>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "noframes": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "noframes", + "children": [ + { + "text": "<body><script><!--...</script></body>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><noframes><body><script><!--...</script></body></noframes></head><body></body></html>", + "noQuirksBodyHtml": "<noframes><body><script><!--...</script></body></noframes>" + } + }, + { + "data": "<!doctype html><textarea><!--<textarea></textarea>--></textarea>", + "errors": [ + "(1,64): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "textarea": true + }, + "doctype": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "textarea", + "children": [ + { + "text": "<!--<textarea>", + "escaped": true + } + ] + }, + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><textarea>&lt;!--&lt;textarea&gt;</textarea>--&gt;</body></html>", + "noQuirksBodyHtml": "<textarea>&lt;!--&lt;textarea&gt;</textarea>--&gt;" + } + }, + { + "data": "<!doctype html><textarea>&lt;/textarea></textarea>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "textarea": true + }, + "doctype": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "textarea", + "children": [ + { + "text": "</textarea>", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><textarea>&lt;/textarea&gt;</textarea></body></html>", + "noQuirksBodyHtml": "<textarea>&lt;/textarea&gt;</textarea>" + } + }, + { + "data": "<!doctype html><textarea>&lt;</textarea>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "textarea": true + }, + "doctype": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "textarea", + "children": [ + { + "text": "<", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><textarea>&lt;</textarea></body></html>", + "noQuirksBodyHtml": "<textarea>&lt;</textarea>" + } + }, + { + "data": "<!doctype html><textarea>a&lt;b</textarea>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "textarea": true + }, + "doctype": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "textarea", + "children": [ + { + "text": "a<b", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><textarea>a&lt;b</textarea></body></html>", + "noQuirksBodyHtml": "<textarea>a&lt;b</textarea>" + } + }, + { + "data": "<!doctype html><iframe><!--<iframe></iframe>--></iframe>", + "errors": [ + "(1,56): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "iframe": true + }, + "doctype": true, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "iframe", + "children": [ + { + "text": "<!--<iframe>", + "no_escape": true + } + ] + }, + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><iframe><!--<iframe></iframe>--&gt;</body></html>", + "noQuirksBodyHtml": "<iframe><!--<iframe></iframe>--&gt;" + } + }, + { + "data": "<!doctype html><iframe>...<!--X->...<!--/X->...</iframe>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "iframe": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "iframe", + "children": [ + { + "text": "...<!--X->...<!--/X->...", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><iframe>...<!--X->...<!--/X->...</iframe></body></html>", + "noQuirksBodyHtml": "<iframe>...<!--X->...<!--/X->...</iframe>" + } + }, + { + "data": "<!doctype html><xmp><!--<xmp></xmp>--></xmp>", + "errors": [ + "(1,44): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "xmp": true + }, + "doctype": true, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "xmp", + "children": [ + { + "text": "<!--<xmp>", + "no_escape": true + } + ] + }, + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><xmp><!--<xmp></xmp>--&gt;</body></html>", + "noQuirksBodyHtml": "<xmp><!--<xmp></xmp>--&gt;" + } + }, + { + "data": "<!doctype html><noembed><!--<noembed></noembed>--></noembed>", + "errors": [ + "(1,60): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "noembed": true + }, + "doctype": true, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "noembed", + "children": [ + { + "text": "<!--<noembed>", + "no_escape": true + } + ] + }, + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><noembed><!--<noembed></noembed>--&gt;</body></html>", + "noQuirksBodyHtml": "<noembed><!--<noembed></noembed>--&gt;" + } + }, + { + "data": "<script>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,8): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script" + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script></script></head><body></body></html>", + "noQuirksBodyHtml": "<script></script>" + } + }, + { + "data": "<script>a", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,9): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "a", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script>a</script></head><body></body></html>", + "noQuirksBodyHtml": "<script>a</script>" + } + }, + { + "data": "<script><", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,9): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><</script>" + } + }, + { + "data": "<script></", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,10): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script></</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></</script>" + } + }, + { + "data": "<script></S", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,11): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</S", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script></S</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></S</script>" + } + }, + { + "data": "<script></SC", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,12): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</SC", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script></SC</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></SC</script>" + } + }, + { + "data": "<script></SCR", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,13): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</SCR", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script></SCR</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></SCR</script>" + } + }, + { + "data": "<script></SCRI", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,14): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</SCRI", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script></SCRI</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></SCRI</script>" + } + }, + { + "data": "<script></SCRIP", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,15): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</SCRIP", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script></SCRIP</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></SCRIP</script>" + } + }, + { + "data": "<script></SCRIPT", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,16): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</SCRIPT", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script></SCRIPT</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></SCRIPT</script>" + } + }, + { + "data": "<script></SCRIPT ", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,17): expected-attribute-name-but-got-eof", + "(1,17): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script" + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script></script></head><body></body></html>", + "noQuirksBodyHtml": "<script></script>" + } + }, + { + "data": "<script></s", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,11): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</s", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script></s</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></s</script>" + } + }, + { + "data": "<script></sc", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,12): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</sc", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script></sc</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></sc</script>" + } + }, + { + "data": "<script></scr", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,13): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</scr", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script></scr</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></scr</script>" + } + }, + { + "data": "<script></scri", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,14): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</scri", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script></scri</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></scri</script>" + } + }, + { + "data": "<script></scrip", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,15): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</scrip", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script></scrip</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></scrip</script>" + } + }, + { + "data": "<script></script", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,16): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</script", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script></script</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></script</script>" + } + }, + { + "data": "<script></script ", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,17): expected-attribute-name-but-got-eof", + "(1,17): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script" + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script></script></head><body></body></html>", + "noQuirksBodyHtml": "<script></script>" + } + }, + { + "data": "<script><!", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,10): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!</script>" + } + }, + { + "data": "<script><!a", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,11): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!a", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!a</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!a</script>" + } + }, + { + "data": "<script><!-", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,11): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!-", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!-</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!-</script>" + } + }, + { + "data": "<script><!-a", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,12): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!-a", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!-a</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!-a</script>" + } + }, + { + "data": "<script><!--", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,12): expected-named-closing-tag-but-got-eof", + "(1,12): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--</script>" + } + }, + { + "data": "<script><!--a", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,13): expected-named-closing-tag-but-got-eof", + "(1,13): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--a", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--a</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--a</script>" + } + }, + { + "data": "<script><!--<", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,13): expected-named-closing-tag-but-got-eof", + "(1,13): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<</script>" + } + }, + { + "data": "<script><!--<a", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,14): expected-named-closing-tag-but-got-eof", + "(1,14): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<a", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<a</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<a</script>" + } + }, + { + "data": "<script><!--</", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,14): expected-named-closing-tag-but-got-eof", + "(1,14): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--</", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--</</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--</</script>" + } + }, + { + "data": "<script><!--</script", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,20): expected-named-closing-tag-but-got-eof", + "(1,20): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--</script", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--</script</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--</script</script>" + } + }, + { + "data": "<script><!--</script ", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,21): expected-attribute-name-but-got-eof", + "(1,21): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--</script>" + } + }, + { + "data": "<script><!--<s", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,14): expected-named-closing-tag-but-got-eof", + "(1,14): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<s", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<s</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<s</script>" + } + }, + { + "data": "<script><!--<script", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,19): expected-named-closing-tag-but-got-eof", + "(1,19): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script</script>" + } + }, + { + "data": "<script><!--<script ", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,20): eof-in-script-in-script", + "(1,20): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script ", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script </script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script>" + } + }, + { + "data": "<script><!--<script <", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,21): eof-in-script-in-script", + "(1,21): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script <", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script <</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script <</script>" + } + }, + { + "data": "<script><!--<script <a", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,22): eof-in-script-in-script", + "(1,22): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script <a", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script <a</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script <a</script>" + } + }, + { + "data": "<script><!--<script </", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,22): eof-in-script-in-script", + "(1,22): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script </</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </</script>" + } + }, + { + "data": "<script><!--<script </s", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,23): eof-in-script-in-script", + "(1,23): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </s", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script </s</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </s</script>" + } + }, + { + "data": "<script><!--<script </script", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,28): eof-in-script-in-script", + "(1,28): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script </script</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script</script>" + } + }, + { + "data": "<script><!--<script </scripta", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,29): eof-in-script-in-script", + "(1,29): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </scripta", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script </scripta</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </scripta</script>" + } + }, + { + "data": "<script><!--<script </script ", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,29): expected-named-closing-tag-but-got-eof", + "(1,29): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script ", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script </script </script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script </script>" + } + }, + { + "data": "<script><!--<script </script>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,29): expected-named-closing-tag-but-got-eof", + "(1,29): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script </script></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script></script>" + } + }, + { + "data": "<script><!--<script </script/", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,29): expected-named-closing-tag-but-got-eof", + "(1,29): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script/", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script </script/</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script/</script>" + } + }, + { + "data": "<script><!--<script </script <", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,30): expected-named-closing-tag-but-got-eof", + "(1,30): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script <", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script </script <</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script <</script>" + } + }, + { + "data": "<script><!--<script </script <a", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,31): expected-named-closing-tag-but-got-eof", + "(1,31): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script <a", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script </script <a</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script <a</script>" + } + }, + { + "data": "<script><!--<script </script </", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,31): expected-named-closing-tag-but-got-eof", + "(1,31): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script </", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script </script </</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script </</script>" + } + }, + { + "data": "<script><!--<script </script </script", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,37): expected-named-closing-tag-but-got-eof", + "(1,37): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script </script", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script </script </script</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script </script</script>" + } + }, + { + "data": "<script><!--<script </script </script ", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,38): expected-attribute-name-but-got-eof", + "(1,38): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script ", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script </script </script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script </script>" + } + }, + { + "data": "<script><!--<script </script </script/", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,38): unexpected-EOF-after-solidus-in-tag", + "(1,38): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script ", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script </script </script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script </script>" + } + }, + { + "data": "<script><!--<script </script </script>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script </script ", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script </script </script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script </script </script>" + } + }, + { + "data": "<script><!--<script -", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,21): eof-in-script-in-script", + "(1,21): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script -", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script -</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script -</script>" + } + }, + { + "data": "<script><!--<script -a", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,22): eof-in-script-in-script", + "(1,22): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script -a", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script -a</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script -a</script>" + } + }, + { + "data": "<script><!--<script --", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,22): eof-in-script-in-script", + "(1,22): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script --", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script --</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --</script>" + } + }, + { + "data": "<script><!--<script --a", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,23): eof-in-script-in-script", + "(1,23): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script --a", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script --a</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --a</script>" + } + }, + { + "data": "<script><!--<script -->", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,23): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script -->", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script --></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --></script>" + } + }, + { + "data": "<script><!--<script --><", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,24): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script --><", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script --><</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --><</script>" + } + }, + { + "data": "<script><!--<script --></", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,25): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script --></", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script --></</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --></</script>" + } + }, + { + "data": "<script><!--<script --></script", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,31): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script --></script", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script --></script</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --></script</script>" + } + }, + { + "data": "<script><!--<script --></script ", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,32): expected-attribute-name-but-got-eof", + "(1,32): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script -->", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script --></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --></script>" + } + }, + { + "data": "<script><!--<script --></script/", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,32): unexpected-EOF-after-solidus-in-tag", + "(1,32): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script -->", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script --></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --></script>" + } + }, + { + "data": "<script><!--<script --></script>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script -->", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script --></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script --></script>" + } + }, + { + "data": "<script><!--<script><\\/script>--></script>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script><\\/script>-->", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script><\\/script>--></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script><\\/script>--></script>" + } + }, + { + "data": "<script><!--<script></scr'+'ipt>--></script>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script></scr'+'ipt>-->", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script></scr'+'ipt>--></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script></scr'+'ipt>--></script>" + } + }, + { + "data": "<script><!--<script></script><script></script></script>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script></script><script></script>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script></script><script></script></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script></script><script></script></script>" + } + }, + { + "data": "<script><!--<script></script><script></script>--><!--</script>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script></script><script></script>--><!--", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script></script><script></script>--><!--</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script></script><script></script>--><!--</script>" + } + }, + { + "data": "<script><!--<script></script><script></script>-- ></script>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script></script><script></script>-- >", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script></script><script></script>-- ></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script></script><script></script>-- ></script>" + } + }, + { + "data": "<script><!--<script></script><script></script>- -></script>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script></script><script></script>- ->", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script></script><script></script>- -></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script></script><script></script>- -></script>" + } + }, + { + "data": "<script><!--<script></script><script></script>- - ></script>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script></script><script></script>- - >", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script></script><script></script>- - ></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script></script><script></script>- - ></script>" + } + }, + { + "data": "<script><!--<script></script><script></script>-></script>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script></script><script></script>->", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script></script><script></script>-></script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script></script><script></script>-></script>" + } + }, + { + "data": "<script><!--<script>--!></script>X", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,34): expected-named-closing-tag-but-got-eof", + "(1,34): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script>--!></script>X", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script>--!></script>X</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script>--!></script>X</script>" + } + }, + { + "data": "<script><!--<scr'+'ipt></script>--></script>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,44): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<scr'+'ipt>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head><script><!--<scr'+'ipt></script></head><body>--&gt;</body></html>", + "noQuirksBodyHtml": "<script><!--<scr'+'ipt></script>--&gt;" + } + }, + { + "data": "<script><!--<script></scr'+'ipt></script>X", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,42): expected-named-closing-tag-but-got-eof", + "(1,42): unexpected-eof-in-text-mode" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "<!--<script></scr'+'ipt></script>X", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script><!--<script></scr'+'ipt></script>X</script></head><body></body></html>", + "noQuirksBodyHtml": "<script><!--<script></scr'+'ipt></script>X</script>" + } + }, + { + "data": "<style><!--<style></style>--></style>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,37): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "<!--<style>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head><style><!--<style></style></head><body>--&gt;</body></html>", + "noQuirksBodyHtml": "<style><!--<style></style>--&gt;" + } + }, + { + "data": "<style><!--</style>X", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "<!--", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ], + "html": "<html><head><style><!--</style></head><body>X</body></html>", + "noQuirksBodyHtml": "<style><!--</style>X" + } + }, + { + "data": "<style><!--...</style>...--></style>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,36): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "<!--...", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "...-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head><style><!--...</style></head><body>...--&gt;</body></html>", + "noQuirksBodyHtml": "<style><!--...</style>...--&gt;" + } + }, + { + "data": "<style><!--<br><html xmlns:v=\"urn:schemas-microsoft-com:vml\"><!--[if !mso]><style></style>X", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "<!--<br><html xmlns:v=\"urn:schemas-microsoft-com:vml\"><!--[if !mso]><style>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ], + "html": "<html><head><style><!--<br><html xmlns:v=\"urn:schemas-microsoft-com:vml\"><!--[if !mso]><style></style></head><body>X</body></html>", + "noQuirksBodyHtml": "<style><!--<br><html xmlns:v=\"urn:schemas-microsoft-com:vml\"><!--[if !mso]><style></style>X" + } + }, + { + "data": "<style><!--...<style><!--...--!></style>--></style>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,51): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "<!--...<style><!--...--!>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head><style><!--...<style><!--...--!></style></head><body>--&gt;</body></html>", + "noQuirksBodyHtml": "<style><!--...<style><!--...--!></style>--&gt;" + } + }, + { + "data": "<style><!--...</style><!-- --><style>@import ...</style>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "no_escape": true, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "<!--...", + "no_escape": true + } + ] + }, + { + "comment": " " + }, + { + "tag": "style", + "children": [ + { + "text": "@import ...", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><style><!--...</style><!-- --><style>@import ...</style></head><body></body></html>", + "noQuirksBodyHtml": "<style><!--...</style><!-- --><style>@import ...</style>" + } + }, + { + "data": "<style>...<style><!--...</style><!-- --></style>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,48): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "no_escape": true, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "...<style><!--...", + "no_escape": true + } + ] + }, + { + "comment": " " + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><style>...<style><!--...</style><!-- --></head><body></body></html>", + "noQuirksBodyHtml": "<style>...<style><!--...</style><!-- -->" + } + }, + { + "data": "<style>...<!--[if IE]><style>...</style>X", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "...<!--[if IE]><style>...", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ], + "html": "<html><head><style>...<!--[if IE]><style>...</style></head><body>X</body></html>", + "noQuirksBodyHtml": "<style>...<!--[if IE]><style>...</style>X" + } + }, + { + "data": "<title><!--<title></title>--></title>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,37): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "<!--<title>", + "escaped": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head><title>&lt;!--&lt;title&gt;</title></head><body>--&gt;</body></html>", + "noQuirksBodyHtml": "<title>&lt;!--&lt;title&gt;</title>--&gt;" + } + }, + { + "data": "<title>&lt;/title></title>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "</title>", + "escaped": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><title>&lt;/title&gt;</title></head><body></body></html>", + "noQuirksBodyHtml": "<title>&lt;/title&gt;</title>" + } + }, + { + "data": "<title>foo/title><link></head><body>X", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,37): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "foo/title><link></head><body>X", + "escaped": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><title>foo/title&gt;&lt;link&gt;&lt;/head&gt;&lt;body&gt;X</title></head><body></body></html>", + "noQuirksBodyHtml": "<title>foo/title&gt;&lt;link&gt;&lt;/head&gt;&lt;body&gt;X</title>" + } + }, + { + "data": "<noscript><!--<noscript></noscript>--></noscript>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(1,49): unexpected-end-tag" + ], + "script": "on", + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "noscript": true, + "body": true + }, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "noscript", + "children": [ + { + "text": "<!--<noscript>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head><noscript><!--<noscript></noscript></head><body>--&gt;</body></html>", + "noQuirksBodyHtml": "<noscript>&lt;!--&lt;noscript&gt;</noscript>--&gt;" + } + }, + { + "data": "<noscript><!--<noscript></noscript>--></noscript>", + "errors": [ + " * (1,11) missing DOCTYPE" + ], + "script": "off", + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "noscript": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "noscript", + "children": [ + { + "comment": "<noscript></noscript>" + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><noscript><!--<noscript></noscript>--></noscript></head><body></body></html>", + "noQuirksBodyHtml": "<noscript>&lt;!--&lt;noscript&gt;</noscript>--&gt;" + } + }, + { + "data": "<noscript><!--</noscript>X<noscript>--></noscript>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag" + ], + "script": "on", + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "noscript": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "noscript", + "children": [ + { + "text": "<!--", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "X" + }, + { + "tag": "noscript", + "children": [ + { + "text": "-->", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head><noscript><!--</noscript></head><body>X<noscript>--></noscript></body></html>", + "noQuirksBodyHtml": "<noscript>&lt;!--</noscript>X<noscript>--&gt;</noscript>" + } + }, + { + "data": "<noscript><!--</noscript>X<noscript>--></noscript>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag" + ], + "script": "off", + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "noscript": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "noscript", + "children": [ + { + "comment": "</noscript>X<noscript>" + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><noscript><!--</noscript>X<noscript>--></noscript></head><body></body></html>", + "noQuirksBodyHtml": "<noscript>&lt;!--</noscript>X<noscript>--&gt;</noscript>" + } + }, + { + "data": "<noscript><iframe></noscript>X", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag" + ], + "script": "on", + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "noscript": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "noscript", + "children": [ + { + "text": "<iframe>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ], + "html": "<html><head><noscript><iframe></noscript></head><body>X</body></html>", + "noQuirksBodyHtml": "<noscript>&lt;iframe&gt;</noscript>X" + } + }, + { + "data": "<noscript><iframe></noscript>X", + "errors": [ + " * (1,11) missing DOCTYPE", + " * (1,19) unexpected token in head noscript", + " * (1,31) unexpected EOF" + ], + "script": "off", + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "noscript": true, + "body": true, + "iframe": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "noscript" + } + ] + }, + { + "tag": "body", + "children": [ + { + "tag": "iframe", + "children": [ + { + "text": "</noscript>X", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head><noscript></noscript></head><body><iframe></noscript>X</iframe></body></html>", + "noQuirksBodyHtml": "<noscript>&lt;iframe&gt;</noscript>X" + } + }, + { + "data": "<noframes><!--<noframes></noframes>--></noframes>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(1,49): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "noframes": true, + "body": true + }, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "noframes", + "children": [ + { + "text": "<!--<noframes>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head><noframes><!--<noframes></noframes></head><body>--&gt;</body></html>", + "noQuirksBodyHtml": "<noframes><!--<noframes></noframes>--&gt;" + } + }, + { + "data": "<noframes><body><script><!--...</script></body></noframes></html>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "noframes": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "noframes", + "children": [ + { + "text": "<body><script><!--...</script></body>", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><noframes><body><script><!--...</script></body></noframes></head><body></body></html>", + "noQuirksBodyHtml": "<noframes><body><script><!--...</script></body></noframes>" + } + }, + { + "data": "<textarea><!--<textarea></textarea>--></textarea>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(1,49): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "textarea": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "textarea", + "children": [ + { + "text": "<!--<textarea>", + "escaped": true + } + ] + }, + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body><textarea>&lt;!--&lt;textarea&gt;</textarea>--&gt;</body></html>", + "noQuirksBodyHtml": "<textarea>&lt;!--&lt;textarea&gt;</textarea>--&gt;" + } + }, + { + "data": "<textarea>&lt;/textarea></textarea>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "textarea": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "textarea", + "children": [ + { + "text": "</textarea>", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><textarea>&lt;/textarea&gt;</textarea></body></html>", + "noQuirksBodyHtml": "<textarea>&lt;/textarea&gt;</textarea>" + } + }, + { + "data": "<iframe><!--<iframe></iframe>--></iframe>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,41): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "iframe": true + }, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "iframe", + "children": [ + { + "text": "<!--<iframe>", + "no_escape": true + } + ] + }, + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body><iframe><!--<iframe></iframe>--&gt;</body></html>", + "noQuirksBodyHtml": "<iframe><!--<iframe></iframe>--&gt;" + } + }, + { + "data": "<iframe>...<!--X->...<!--/X->...</iframe>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "iframe": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "iframe", + "children": [ + { + "text": "...<!--X->...<!--/X->...", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><iframe>...<!--X->...<!--/X->...</iframe></body></html>", + "noQuirksBodyHtml": "<iframe>...<!--X->...<!--/X->...</iframe>" + } + }, + { + "data": "<xmp><!--<xmp></xmp>--></xmp>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,29): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "xmp": true + }, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "xmp", + "children": [ + { + "text": "<!--<xmp>", + "no_escape": true + } + ] + }, + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body><xmp><!--<xmp></xmp>--&gt;</body></html>", + "noQuirksBodyHtml": "<xmp><!--<xmp></xmp>--&gt;" + } + }, + { + "data": "<noembed><!--<noembed></noembed>--></noembed>", + "errors": [ + "(1,9): expected-doctype-but-got-start-tag", + "(1,45): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "noembed": true + }, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "noembed", + "children": [ + { + "text": "<!--<noembed>", + "no_escape": true + } + ] + }, + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body><noembed><!--<noembed></noembed>--&gt;</body></html>", + "noQuirksBodyHtml": "<noembed><!--<noembed></noembed>--&gt;" + } + }, + { + "data": "<!doctype html><table>\n", + "errors": [ + "(2,0): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "text": "\n" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table>\n</table></body></html>", + "noQuirksBodyHtml": "<table>\n</table>" + } + }, + { + "data": "<!doctype html><table><td><span><font></span><span>", + "errors": [ + "(1,26): unexpected-cell-in-table-body", + "(1,45): unexpected-end-tag", + "(1,51): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "span": true, + "font": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "span", + "children": [ + { + "tag": "font" + } + ] + }, + { + "tag": "font", + "children": [ + { + "tag": "span" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><span><font></font></span><font><span></span></font></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><span><font></font></span><font><span></span></font></td></tr></tbody></table>" + } + }, + { + "data": "<!doctype html><form><table></form><form></table></form>", + "errors": [ + "(1,35): unexpected-end-tag-implies-table-voodoo", + "(1,35): unexpected-end-tag", + "(1,41): unexpected-form-in-table", + "(1,56): unexpected-end-tag", + "(1,56): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "form": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "form", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "form" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><form><table><form></form></table></form></body></html>", + "noQuirksBodyHtml": "<form><table><form></form></table></form>" + } + } + ], + "tests17.dat": [ + { + "data": "<!doctype html><table><tbody><select><tr>", + "errors": [ + "(1,37): unexpected-start-tag-implies-table-voodoo", + "(1,41): unexpected-table-element-start-tag-in-select-in-table", + "(1,41): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "table": true, + "tbody": true, + "tr": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select></select><table><tbody><tr></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<select></select><table><tbody><tr></tr></tbody></table>" + } + }, + { + "data": "<!doctype html><table><tr><select><td>", + "errors": [ + "(1,34): unexpected-start-tag-implies-table-voodoo", + "(1,38): unexpected-table-element-start-tag-in-select-in-table", + "(1,38): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select></select><table><tbody><tr><td></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<select></select><table><tbody><tr><td></td></tr></tbody></table>" + } + }, + { + "data": "<!doctype html><table><tr><td><select><td>", + "errors": [ + "(1,42): unexpected-table-element-start-tag-in-select-in-table", + "(1,42): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "select": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "select" + } + ] + }, + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><select></select></td><td></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><select></select></td><td></td></tr></tbody></table>" + } + }, + { + "data": "<!doctype html><table><tr><th><select><td>", + "errors": [ + "(1,42): unexpected-table-element-start-tag-in-select-in-table", + "(1,42): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "th": true, + "select": true, + "td": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "th", + "children": [ + { + "tag": "select" + } + ] + }, + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><th><select></select></th><td></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><th><select></select></th><td></td></tr></tbody></table>" + } + }, + { + "data": "<!doctype html><table><caption><select><tr>", + "errors": [ + "(1,43): unexpected-table-element-start-tag-in-select-in-table", + "(1,43): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "caption": true, + "select": true, + "tbody": true, + "tr": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "caption", + "children": [ + { + "tag": "select" + } + ] + }, + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><caption><select></select></caption><tbody><tr></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><caption><select></select></caption><tbody><tr></tr></tbody></table>" + } + }, + { + "data": "<!doctype html><select><tr>", + "errors": [ + "(1,27): unexpected-start-tag-in-select", + "(1,27): eof-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>", + "noQuirksBodyHtml": "<select></select>" + } + }, + { + "data": "<!doctype html><select><td>", + "errors": [ + "(1,27): unexpected-start-tag-in-select", + "(1,27): eof-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>", + "noQuirksBodyHtml": "<select></select>" + } + }, + { + "data": "<!doctype html><select><th>", + "errors": [ + "(1,27): unexpected-start-tag-in-select", + "(1,27): eof-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>", + "noQuirksBodyHtml": "<select></select>" + } + }, + { + "data": "<!doctype html><select><tbody>", + "errors": [ + "(1,30): unexpected-start-tag-in-select", + "(1,30): eof-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>", + "noQuirksBodyHtml": "<select></select>" + } + }, + { + "data": "<!doctype html><select><thead>", + "errors": [ + "(1,30): unexpected-start-tag-in-select", + "(1,30): eof-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>", + "noQuirksBodyHtml": "<select></select>" + } + }, + { + "data": "<!doctype html><select><tfoot>", + "errors": [ + "(1,30): unexpected-start-tag-in-select", + "(1,30): eof-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>", + "noQuirksBodyHtml": "<select></select>" + } + }, + { + "data": "<!doctype html><select><caption>", + "errors": [ + "(1,32): unexpected-start-tag-in-select", + "(1,32): eof-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>", + "noQuirksBodyHtml": "<select></select>" + } + }, + { + "data": "<!doctype html><table><tr></table>a", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + }, + { + "text": "a" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr></tr></tbody></table>a</body></html>", + "noQuirksBodyHtml": "<table><tbody><tr></tr></tbody></table>a" + } + } + ], + "tests18.dat": [ + { + "data": "<!doctype html><plaintext></plaintext>", + "errors": [ + "(1,38): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "plaintext": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "plaintext", + "children": [ + { + "text": "</plaintext>", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><plaintext></plaintext></plaintext></body></html>", + "noQuirksBodyHtml": "<plaintext></plaintext></plaintext>" + } + }, + { + "data": "<!doctype html><table><plaintext></plaintext>", + "errors": [ + "(1,33): foster-parenting-start-tag", + "(1,34): foster-parenting-character", + "(1,35): foster-parenting-character", + "(1,36): foster-parenting-character", + "(1,37): foster-parenting-character", + "(1,38): foster-parenting-character", + "(1,39): foster-parenting-character", + "(1,40): foster-parenting-character", + "(1,41): foster-parenting-character", + "(1,42): foster-parenting-character", + "(1,43): foster-parenting-character", + "(1,44): foster-parenting-character", + "(1,45): foster-parenting-character", + "(1,45): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "plaintext": true, + "table": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "plaintext", + "children": [ + { + "text": "</plaintext>", + "no_escape": true + } + ] + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><plaintext></plaintext></plaintext><table></table></body></html>", + "noQuirksBodyHtml": "<plaintext></plaintext></plaintext><table></table>" + } + }, + { + "data": "<!doctype html><table><tbody><plaintext></plaintext>", + "errors": [ + "(1,40): foster-parenting-start-tag", + "(1,41): foster-parenting-character", + "(1,41): foster-parenting-character", + "(1,41): foster-parenting-character", + "(1,41): foster-parenting-character", + "(1,41): foster-parenting-character", + "(1,41): foster-parenting-character", + "(1,41): foster-parenting-character", + "(1,41): foster-parenting-character", + "(1,41): foster-parenting-character", + "(1,41): foster-parenting-character", + "(1,41): foster-parenting-character", + "(1,41): foster-parenting-character", + "(1,52): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "plaintext": true, + "table": true, + "tbody": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "plaintext", + "children": [ + { + "text": "</plaintext>", + "no_escape": true + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><plaintext></plaintext></plaintext><table><tbody></tbody></table></body></html>", + "noQuirksBodyHtml": "<plaintext></plaintext></plaintext><table><tbody></tbody></table>" + } + }, + { + "data": "<!doctype html><table><tbody><tr><plaintext></plaintext>", + "errors": [ + "(1,44): foster-parenting-start-tag", + "(1,45): foster-parenting-character", + "(1,46): foster-parenting-character", + "(1,47): foster-parenting-character", + "(1,48): foster-parenting-character", + "(1,49): foster-parenting-character", + "(1,50): foster-parenting-character", + "(1,51): foster-parenting-character", + "(1,52): foster-parenting-character", + "(1,53): foster-parenting-character", + "(1,54): foster-parenting-character", + "(1,55): foster-parenting-character", + "(1,56): foster-parenting-character", + "(1,56): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "plaintext": true, + "table": true, + "tbody": true, + "tr": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "plaintext", + "children": [ + { + "text": "</plaintext>", + "no_escape": true + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><plaintext></plaintext></plaintext><table><tbody><tr></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<plaintext></plaintext></plaintext><table><tbody><tr></tr></tbody></table>" + } + }, + { + "data": "<!doctype html><table><td><plaintext></plaintext>", + "errors": [ + "(1,26): unexpected-cell-in-table-body", + "(1,49): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "plaintext": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "plaintext", + "children": [ + { + "text": "</plaintext>", + "no_escape": true + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><plaintext></plaintext></plaintext></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><plaintext></plaintext></plaintext></td></tr></tbody></table>" + } + }, + { + "data": "<!doctype html><table><caption><plaintext></plaintext>", + "errors": [ + "(1,54): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "caption": true, + "plaintext": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "caption", + "children": [ + { + "tag": "plaintext", + "children": [ + { + "text": "</plaintext>", + "no_escape": true + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><caption><plaintext></plaintext></plaintext></caption></table></body></html>", + "noQuirksBodyHtml": "<table><caption><plaintext></plaintext></plaintext></caption></table>" + } + }, + { + "data": "<!doctype html><table><tr><style></script></style>abc", + "errors": [ + "(1,51): foster-parenting-character", + "(1,52): foster-parenting-character", + "(1,53): foster-parenting-character", + "(1,53): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "style": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "abc" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "</script>", + "no_escape": true + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>abc<table><tbody><tr><style></script></style></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "abc<table><tbody><tr><style></script></style></tr></tbody></table>" + } + }, + { + "data": "<!doctype html><table><tr><script></style></script>abc", + "errors": [ + "(1,52): foster-parenting-character", + "(1,53): foster-parenting-character", + "(1,54): foster-parenting-character", + "(1,54): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "script": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "abc" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</style>", + "no_escape": true + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>abc<table><tbody><tr><script></style></script></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "abc<table><tbody><tr><script></style></script></tr></tbody></table>" + } + }, + { + "data": "<!doctype html><table><caption><style></script></style>abc", + "errors": [ + "(1,58): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "caption": true, + "style": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "caption", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "</script>", + "no_escape": true + } + ] + }, + { + "text": "abc" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><caption><style></script></style>abc</caption></table></body></html>", + "noQuirksBodyHtml": "<table><caption><style></script></style>abc</caption></table>" + } + }, + { + "data": "<!doctype html><table><td><style></script></style>abc", + "errors": [ + "(1,26): unexpected-cell-in-table-body", + "(1,53): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "style": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "style", + "children": [ + { + "text": "</script>", + "no_escape": true + } + ] + }, + { + "text": "abc" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><style></script></style>abc</td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><style></script></style>abc</td></tr></tbody></table>" + } + }, + { + "data": "<!doctype html><select><script></style></script>abc", + "errors": [ + "(1,51): eof-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "script": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</style>", + "no_escape": true + } + ] + }, + { + "text": "abc" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select><script></style></script>abc</select></body></html>", + "noQuirksBodyHtml": "<select><script></style></script>abc</select>" + } + }, + { + "data": "<!doctype html><table><select><script></style></script>abc", + "errors": [ + "(1,30): unexpected-start-tag-implies-table-voodoo", + "(1,58): eof-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "script": true, + "table": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</style>", + "no_escape": true + } + ] + }, + { + "text": "abc" + } + ] + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select><script></style></script>abc</select><table></table></body></html>", + "noQuirksBodyHtml": "<select><script></style></script>abc</select><table></table>" + } + }, + { + "data": "<!doctype html><table><tr><select><script></style></script>abc", + "errors": [ + "(1,34): unexpected-start-tag-implies-table-voodoo", + "(1,62): eof-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "script": true, + "table": true, + "tbody": true, + "tr": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</style>", + "no_escape": true + } + ] + }, + { + "text": "abc" + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select><script></style></script>abc</select><table><tbody><tr></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<select><script></style></script>abc</select><table><tbody><tr></tr></tbody></table>" + } + }, + { + "data": "<!doctype html><frameset></frameset><noframes>abc", + "errors": [ + "(1,49): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true, + "noframes": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + }, + { + "tag": "noframes", + "children": [ + { + "text": "abc", + "no_escape": true + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset><noframes>abc</noframes></html>", + "noQuirksBodyHtml": "<noframes>abc</noframes>" + } + }, + { + "data": "<!doctype html><frameset></frameset><noframes>abc</noframes><!--abc-->", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true, + "noframes": true + }, + "doctype": true, + "no_escape": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + }, + { + "tag": "noframes", + "children": [ + { + "text": "abc", + "no_escape": true + } + ] + }, + { + "comment": "abc" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset><noframes>abc</noframes><!--abc--></html>", + "noQuirksBodyHtml": "<noframes>abc</noframes><!--abc-->" + } + }, + { + "data": "<!doctype html><frameset></frameset></html><noframes>abc", + "errors": [ + "(1,56): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true, + "noframes": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + }, + { + "tag": "noframes", + "children": [ + { + "text": "abc", + "no_escape": true + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset><noframes>abc</noframes></html>", + "noQuirksBodyHtml": "<noframes>abc</noframes>" + } + }, + { + "data": "<!doctype html><frameset></frameset></html><noframes>abc</noframes><!--abc-->", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true, + "noframes": true + }, + "doctype": true, + "no_escape": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + }, + { + "tag": "noframes", + "children": [ + { + "text": "abc", + "no_escape": true + } + ] + } + ] + }, + { + "comment": "abc" + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset><noframes>abc</noframes></html><!--abc-->", + "noQuirksBodyHtml": "<noframes>abc</noframes><!--abc-->" + } + }, + { + "data": "<!doctype html><table><tr></tbody><tfoot>", + "errors": [ + "(1,41): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "tfoot": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + }, + { + "tag": "tfoot" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr></tr></tbody><tfoot></tfoot></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr></tr></tbody><tfoot></tfoot></table>" + } + }, + { + "data": "<!doctype html><table><td><svg></svg>abc<td>", + "errors": [ + "(1,26): unexpected-cell-in-table-body", + "(1,44): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "svg svg": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + }, + { + "text": "abc" + } + ] + }, + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><svg></svg>abc</td><td></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><svg></svg>abc</td><td></td></tr></tbody></table>" + } + } + ], + "tests19.dat": [ + { + "data": "<!doctype html><math><mn DefinitionUrl=\"foo\">", + "errors": [ + "(1,45): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mn": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mn", + "ns": "http://www.w3.org/1998/Math/MathML", + "attrs": [ + { + "name": "definitionURL", + "value": "foo" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math><mn definitionURL=\"foo\"></mn></math></body></html>", + "noQuirksBodyHtml": "<math><mn definitionURL=\"foo\"></mn></math>" + } + }, + { + "data": "<!doctype html><html></p><!--foo-->", + "errors": [ + "(1,25): end-tag-after-implied-root" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "comment": "foo" + }, + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><!--foo--><head></head><body></body></html>", + "noQuirksBodyHtml": "<p></p><!--foo-->" + } + }, + { + "data": "<!doctype html><head></head></p><!--foo-->", + "errors": [ + "(1,32): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "comment": "foo" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><!--foo--><body></body></html>", + "noQuirksBodyHtml": "<p></p><!--foo-->" + } + }, + { + "data": "<!doctype html><body><p><pre>", + "errors": [ + "(1,29): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "pre": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p" + }, + { + "tag": "pre" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p></p><pre></pre></body></html>", + "noQuirksBodyHtml": "<p></p><pre></pre>" + } + }, + { + "data": "<!doctype html><body><p><listing>", + "errors": [ + "(1,33): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "listing": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p" + }, + { + "tag": "listing" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p></p><listing></listing></body></html>", + "noQuirksBodyHtml": "<p></p><listing></listing>" + } + }, + { + "data": "<!doctype html><p><plaintext>", + "errors": [ + "(1,29): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "plaintext": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p" + }, + { + "tag": "plaintext" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p></p><plaintext></plaintext></body></html>", + "noQuirksBodyHtml": "<p></p><plaintext></plaintext>" + } + }, + { + "data": "<!doctype html><p><h1>", + "errors": [ + "(1,22): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "h1": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p" + }, + { + "tag": "h1" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p></p><h1></h1></body></html>", + "noQuirksBodyHtml": "<p></p><h1></h1>" + } + }, + { + "data": "<!doctype html><form><isindex>", + "errors": [ + "(1,30): deprecated-tag", + "(1,30): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "form": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "form" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><form></form></body></html>", + "noQuirksBodyHtml": "<form></form>" + } + }, + { + "data": "<!doctype html><isindex action=\"POST\">", + "errors": [ + "(1,38): deprecated-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "form": true, + "hr": true, + "label": true, + "input": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "form", + "attrs": [ + { + "name": "action", + "value": "POST" + } + ], + "children": [ + { + "tag": "hr" + }, + { + "tag": "label", + "children": [ + { + "text": "This is a searchable index. Enter search keywords: " + }, + { + "tag": "input", + "attrs": [ + { + "name": "name", + "value": "isindex" + } + ] + } + ] + }, + { + "tag": "hr" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><form action=\"POST\"><hr><label>This is a searchable index. Enter search keywords: <input name=\"isindex\"></label><hr></form></body></html>", + "noQuirksBodyHtml": "<form action=\"POST\"><hr><label>This is a searchable index. Enter search keywords: <input name=\"isindex\"></label><hr></form>" + } + }, + { + "data": "<!doctype html><isindex prompt=\"this is isindex\">", + "errors": [ + "(1,49): deprecated-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "form": true, + "hr": true, + "label": true, + "input": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "form", + "children": [ + { + "tag": "hr" + }, + { + "tag": "label", + "children": [ + { + "text": "this is isindex" + }, + { + "tag": "input", + "attrs": [ + { + "name": "name", + "value": "isindex" + } + ] + } + ] + }, + { + "tag": "hr" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><form><hr><label>this is isindex<input name=\"isindex\"></label><hr></form></body></html>", + "noQuirksBodyHtml": "<form><hr><label>this is isindex<input name=\"isindex\"></label><hr></form>" + } + }, + { + "data": "<!doctype html><isindex type=\"hidden\">", + "errors": [ + "(1,38): deprecated-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "form": true, + "hr": true, + "label": true, + "input": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "form", + "children": [ + { + "tag": "hr" + }, + { + "tag": "label", + "children": [ + { + "text": "This is a searchable index. Enter search keywords: " + }, + { + "tag": "input", + "attrs": [ + { + "name": "name", + "value": "isindex" + }, + { + "name": "type", + "value": "hidden" + } + ] + } + ] + }, + { + "tag": "hr" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><form><hr><label>This is a searchable index. Enter search keywords: <input name=\"isindex\" type=\"hidden\"></label><hr></form></body></html>", + "noQuirksBodyHtml": "<form><hr><label>This is a searchable index. Enter search keywords: <input name=\"isindex\" type=\"hidden\"></label><hr></form>" + } + }, + { + "data": "<!doctype html><isindex name=\"foo\">", + "errors": [ + "(1,35): deprecated-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "form": true, + "hr": true, + "label": true, + "input": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "form", + "children": [ + { + "tag": "hr" + }, + { + "tag": "label", + "children": [ + { + "text": "This is a searchable index. Enter search keywords: " + }, + { + "tag": "input", + "attrs": [ + { + "name": "name", + "value": "isindex" + } + ] + } + ] + }, + { + "tag": "hr" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><form><hr><label>This is a searchable index. Enter search keywords: <input name=\"isindex\"></label><hr></form></body></html>", + "noQuirksBodyHtml": "<form><hr><label>This is a searchable index. Enter search keywords: <input name=\"isindex\"></label><hr></form>" + } + }, + { + "data": "<!doctype html><ruby><p><rp>", + "errors": [ + "(1,28): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "p": true, + "rp": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "tag": "p" + }, + { + "tag": "rp" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><ruby><p></p><rp></rp></ruby></body></html>", + "noQuirksBodyHtml": "<ruby><p></p><rp></rp></ruby>" + } + }, + { + "data": "<!doctype html><ruby><div><span><rp>", + "errors": [ + "(1,36): XXX-undefined-error", + "(1,36): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "div": true, + "span": true, + "rp": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "span", + "children": [ + { + "tag": "rp" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><ruby><div><span><rp></rp></span></div></ruby></body></html>", + "noQuirksBodyHtml": "<ruby><div><span><rp></rp></span></div></ruby>" + } + }, + { + "data": "<!doctype html><ruby><div><p><rp>", + "errors": [ + "(1,33): XXX-undefined-error", + "(1,33): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "div": true, + "p": true, + "rp": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "p" + }, + { + "tag": "rp" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><ruby><div><p></p><rp></rp></div></ruby></body></html>", + "noQuirksBodyHtml": "<ruby><div><p></p><rp></rp></div></ruby>" + } + }, + { + "data": "<!doctype html><ruby><p><rt>", + "errors": [ + "(1,28): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "p": true, + "rt": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "tag": "p" + }, + { + "tag": "rt" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><ruby><p></p><rt></rt></ruby></body></html>", + "noQuirksBodyHtml": "<ruby><p></p><rt></rt></ruby>" + } + }, + { + "data": "<!doctype html><ruby><div><span><rt>", + "errors": [ + "(1,36): XXX-undefined-error", + "(1,36): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "div": true, + "span": true, + "rt": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "span", + "children": [ + { + "tag": "rt" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><ruby><div><span><rt></rt></span></div></ruby></body></html>", + "noQuirksBodyHtml": "<ruby><div><span><rt></rt></span></div></ruby>" + } + }, + { + "data": "<!doctype html><ruby><div><p><rt>", + "errors": [ + "(1,33): XXX-undefined-error", + "(1,33): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "div": true, + "p": true, + "rt": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "p" + }, + { + "tag": "rt" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><ruby><div><p></p><rt></rt></div></ruby></body></html>", + "noQuirksBodyHtml": "<ruby><div><p></p><rt></rt></div></ruby>" + } + }, + { + "data": "<html><ruby>a<rb>b<rt></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rb": true, + "rt": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rb", + "children": [ + { + "text": "b" + } + ] + }, + { + "tag": "rt" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rb>b</rb><rt></rt></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rb>b</rb><rt></rt></ruby>" + } + }, + { + "data": "<html><ruby>a<rp>b<rt></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rp": true, + "rt": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rp", + "children": [ + { + "text": "b" + } + ] + }, + { + "tag": "rt" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rp>b</rp><rt></rt></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rp>b</rp><rt></rt></ruby>" + } + }, + { + "data": "<html><ruby>a<rt>b<rt></ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rt": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rt", + "children": [ + { + "text": "b" + } + ] + }, + { + "tag": "rt" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rt>b</rt><rt></rt></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rt>b</rt><rt></rt></ruby>" + } + }, + { + "data": "<html><ruby>a<rtc>b<rt>c<rb>d</ruby></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "rtc": true, + "rt": true, + "rb": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "text": "a" + }, + { + "tag": "rtc", + "children": [ + { + "text": "b" + }, + { + "tag": "rt", + "children": [ + { + "text": "c" + } + ] + } + ] + }, + { + "tag": "rb", + "children": [ + { + "text": "d" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby>a<rtc>b<rt>c</rt></rtc><rb>d</rb></ruby></body></html>", + "noQuirksBodyHtml": "<ruby>a<rtc>b<rt>c</rt></rtc><rb>d</rb></ruby>" + } + }, + { + "data": "<!doctype html><math/><foo>", + "errors": [ + "(1,27): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "foo": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "foo" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math></math><foo></foo></body></html>", + "noQuirksBodyHtml": "<math></math><foo></foo>" + } + }, + { + "data": "<!doctype html><svg/><foo>", + "errors": [ + "(1,26): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "foo": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "foo" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg></svg><foo></foo></body></html>", + "noQuirksBodyHtml": "<svg></svg><foo></foo>" + } + }, + { + "data": "<!doctype html><div></body><!--foo-->", + "errors": [ + "(1,27): expected-one-end-tag-but-got-another" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "doctype": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div" + } + ] + }, + { + "comment": "foo" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><div></div></body><!--foo--></html>", + "noQuirksBodyHtml": "<div><!--foo--></div>" + } + }, + { + "data": "<!doctype html><h1><div><h3><span></h1>foo", + "errors": [ + "(1,39): end-tag-too-early", + "(1,42): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "h1": true, + "div": true, + "h3": true, + "span": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "h1", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "h3", + "children": [ + { + "tag": "span" + } + ] + }, + { + "text": "foo" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><h1><div><h3><span></span></h3>foo</div></h1></body></html>", + "noQuirksBodyHtml": "<h1><div><h3><span></span></h3>foo</div></h1>" + } + }, + { + "data": "<!doctype html><p></h3>foo", + "errors": [ + "(1,23): end-tag-too-early" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "text": "foo" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p>foo</p></body></html>", + "noQuirksBodyHtml": "<p>foo</p>" + } + }, + { + "data": "<!doctype html><h3><li>abc</h2>foo", + "errors": [ + "(1,31): end-tag-too-early" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "h3": true, + "li": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "h3", + "children": [ + { + "tag": "li", + "children": [ + { + "text": "abc" + } + ] + } + ] + }, + { + "text": "foo" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><h3><li>abc</li></h3>foo</body></html>", + "noQuirksBodyHtml": "<h3><li>abc</li></h3>foo" + } + }, + { + "data": "<!doctype html><table>abc<!--foo-->", + "errors": [ + "(1,23): foster-parenting-character", + "(1,24): foster-parenting-character", + "(1,25): foster-parenting-character", + "(1,35): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true + }, + "doctype": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "abc" + }, + { + "tag": "table", + "children": [ + { + "comment": "foo" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>abc<table><!--foo--></table></body></html>", + "noQuirksBodyHtml": "abc<table><!--foo--></table>" + } + }, + { + "data": "<!doctype html><table> <!--foo-->", + "errors": [ + "(1,34): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true + }, + "doctype": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "text": " " + }, + { + "comment": "foo" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table> <!--foo--></table></body></html>", + "noQuirksBodyHtml": "<table> <!--foo--></table>" + } + }, + { + "data": "<!doctype html><table> b <!--foo-->", + "errors": [ + "(1,23): foster-parenting-character", + "(1,24): foster-parenting-character", + "(1,25): foster-parenting-character", + "(1,35): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true + }, + "doctype": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": " b " + }, + { + "tag": "table", + "children": [ + { + "comment": "foo" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body> b <table><!--foo--></table></body></html>", + "noQuirksBodyHtml": " b <table><!--foo--></table>" + } + }, + { + "data": "<!doctype html><select><option><option>", + "errors": [ + "(1,39): eof-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "option": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "option" + }, + { + "tag": "option" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select><option></option><option></option></select></body></html>", + "noQuirksBodyHtml": "<select><option></option><option></option></select>" + } + }, + { + "data": "<!doctype html><select><option></optgroup>", + "errors": [ + "(1,42): unexpected-end-tag-in-select", + "(1,42): eof-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "option": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "option" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select><option></option></select></body></html>", + "noQuirksBodyHtml": "<select><option></option></select>" + } + }, + { + "data": "<!doctype html><select><option></optgroup>", + "errors": [ + "(1,42): unexpected-end-tag-in-select", + "(1,42): eof-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "option": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "option" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select><option></option></select></body></html>", + "noQuirksBodyHtml": "<select><option></option></select>" + } + }, + { + "data": "<!doctype html><dd><optgroup><dd>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "dd": true, + "optgroup": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "dd", + "children": [ + { + "tag": "optgroup" + } + ] + }, + { + "tag": "dd" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><dd><optgroup></optgroup></dd><dd></dd></body></html>", + "noQuirksBodyHtml": "<dd><optgroup></optgroup></dd><dd></dd>" + } + }, + { + "data": "<!doctype html><p><math><mi><p><h1>", + "errors": [ + "(1,35): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "math math": true, + "math mi": true, + "h1": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "p" + }, + { + "tag": "h1" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><math><mi><p></p><h1></h1></mi></math></p></body></html>", + "noQuirksBodyHtml": "<p><math><mi><p></p><h1></h1></mi></math></p>" + } + }, + { + "data": "<!doctype html><p><math><mo><p><h1>", + "errors": [ + "(1,35): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "math math": true, + "math mo": true, + "h1": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mo", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "p" + }, + { + "tag": "h1" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><math><mo><p></p><h1></h1></mo></math></p></body></html>", + "noQuirksBodyHtml": "<p><math><mo><p></p><h1></h1></mo></math></p>" + } + }, + { + "data": "<!doctype html><p><math><mn><p><h1>", + "errors": [ + "(1,35): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "math math": true, + "math mn": true, + "h1": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mn", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "p" + }, + { + "tag": "h1" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><math><mn><p></p><h1></h1></mn></math></p></body></html>", + "noQuirksBodyHtml": "<p><math><mn><p></p><h1></h1></mn></math></p>" + } + }, + { + "data": "<!doctype html><p><math><ms><p><h1>", + "errors": [ + "(1,35): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "math math": true, + "math ms": true, + "h1": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "ms", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "p" + }, + { + "tag": "h1" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><math><ms><p></p><h1></h1></ms></math></p></body></html>", + "noQuirksBodyHtml": "<p><math><ms><p></p><h1></h1></ms></math></p>" + } + }, + { + "data": "<!doctype html><p><math><mtext><p><h1>", + "errors": [ + "(1,38): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "math math": true, + "math mtext": true, + "h1": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mtext", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "p" + }, + { + "tag": "h1" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><math><mtext><p></p><h1></h1></mtext></math></p></body></html>", + "noQuirksBodyHtml": "<p><math><mtext><p></p><h1></h1></mtext></math></p>" + } + }, + { + "data": "<!doctype html><frameset></noframes>", + "errors": [ + "(1,36): unexpected-end-tag-in-frameset", + "(1,36): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<!doctype html><html c=d><body></html><html a=b>", + "errors": [ + "(1,48): non-html-root" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "attrs": [ + { + "name": "a", + "value": "b" + }, + { + "name": "c", + "value": "d" + } + ], + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html c=\"d\" a=\"b\"><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<!doctype html><html c=d><frameset></frameset></html><html a=b>", + "errors": [ + "(1,63): non-html-root" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "attrs": [ + { + "name": "a", + "value": "b" + }, + { + "name": "c", + "value": "d" + } + ], + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<!DOCTYPE html><html c=\"d\" a=\"b\"><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<!doctype html><html><frameset></frameset></html><!--foo-->", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + }, + "doctype": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + }, + { + "comment": "foo" + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html><!--foo-->", + "noQuirksBodyHtml": "<!--foo-->" + } + }, + { + "data": "<!doctype html><html><frameset></frameset></html> ", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + }, + { + "text": " " + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset> </html>", + "noQuirksBodyHtml": " " + } + }, + { + "data": "<!doctype html><html><frameset></frameset></html>abc", + "errors": [ + "(1,50): expected-eof-but-got-char", + "(1,51): expected-eof-but-got-char", + "(1,52): expected-eof-but-got-char" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "abc" + } + }, + { + "data": "<!doctype html><html><frameset></frameset></html><p>", + "errors": [ + "(1,52): expected-eof-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<p></p>" + } + }, + { + "data": "<!doctype html><html><frameset></frameset></html></p>", + "errors": [ + "(1,53): expected-eof-but-got-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<p></p>" + } + }, + { + "data": "<html><frameset></frameset></html><!doctype html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,49): unexpected-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<!doctype html><body><frameset>", + "errors": [ + "(1,31): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<!doctype html><p><frameset><frame>", + "errors": [ + "(1,28): unexpected-start-tag", + "(1,35): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true, + "frame": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset", + "children": [ + { + "tag": "frame" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset><frame></frameset></html>", + "noQuirksBodyHtml": "<p></p>" + } + }, + { + "data": "<!doctype html><p>a<frameset>", + "errors": [ + "(1,29): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "text": "a" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p>a</p></body></html>", + "noQuirksBodyHtml": "<p>a</p>" + } + }, + { + "data": "<!doctype html><p> <frameset><frame>", + "errors": [ + "(1,29): unexpected-start-tag", + "(1,36): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true, + "frame": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset", + "children": [ + { + "tag": "frame" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset><frame></frameset></html>", + "noQuirksBodyHtml": "<p> </p>" + } + }, + { + "data": "<!doctype html><pre><frameset>", + "errors": [ + "(1,30): unexpected-start-tag", + "(1,30): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "pre": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "pre" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><pre></pre></body></html>", + "noQuirksBodyHtml": "<pre></pre>" + } + }, + { + "data": "<!doctype html><listing><frameset>", + "errors": [ + "(1,34): unexpected-start-tag", + "(1,34): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "listing": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "listing" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><listing></listing></body></html>", + "noQuirksBodyHtml": "<listing></listing>" + } + }, + { + "data": "<!doctype html><li><frameset>", + "errors": [ + "(1,29): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "li": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "li" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><li></li></body></html>", + "noQuirksBodyHtml": "<li></li>" + } + }, + { + "data": "<!doctype html><dd><frameset>", + "errors": [ + "(1,29): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "dd": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "dd" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><dd></dd></body></html>", + "noQuirksBodyHtml": "<dd></dd>" + } + }, + { + "data": "<!doctype html><dt><frameset>", + "errors": [ + "(1,29): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "dt": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "dt" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><dt></dt></body></html>", + "noQuirksBodyHtml": "<dt></dt>" + } + }, + { + "data": "<!doctype html><button><frameset>", + "errors": [ + "(1,33): unexpected-start-tag", + "(1,33): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "button": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "button" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><button></button></body></html>", + "noQuirksBodyHtml": "<button></button>" + } + }, + { + "data": "<!doctype html><applet><frameset>", + "errors": [ + "(1,33): unexpected-start-tag", + "(1,33): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "applet": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "applet" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><applet></applet></body></html>", + "noQuirksBodyHtml": "<applet></applet>" + } + }, + { + "data": "<!doctype html><marquee><frameset>", + "errors": [ + "(1,34): unexpected-start-tag", + "(1,34): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "marquee": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "marquee" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><marquee></marquee></body></html>", + "noQuirksBodyHtml": "<marquee></marquee>" + } + }, + { + "data": "<!doctype html><object><frameset>", + "errors": [ + "(1,33): unexpected-start-tag", + "(1,33): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "object": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "object" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><object></object></body></html>", + "noQuirksBodyHtml": "<object></object>" + } + }, + { + "data": "<!doctype html><table><frameset>", + "errors": [ + "(1,32): unexpected-start-tag-implies-table-voodoo", + "(1,32): unexpected-start-tag", + "(1,32): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table></table></body></html>", + "noQuirksBodyHtml": "<table></table>" + } + }, + { + "data": "<!doctype html><area><frameset>", + "errors": [ + "(1,31): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "area": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "area" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><area></body></html>", + "noQuirksBodyHtml": "<area>" + } + }, + { + "data": "<!doctype html><basefont><frameset>", + "errors": [ + "(1,35): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "basefont": true, + "frameset": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "basefont" + } + ] + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><basefont></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<basefont>" + } + }, + { + "data": "<!doctype html><bgsound><frameset>", + "errors": [ + "(1,34): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "bgsound": true, + "frameset": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "bgsound" + } + ] + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><bgsound></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<bgsound>" + } + }, + { + "data": "<!doctype html><br><frameset>", + "errors": [ + "(1,29): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "br": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "br" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><br></body></html>", + "noQuirksBodyHtml": "<br>" + } + }, + { + "data": "<!doctype html><embed><frameset>", + "errors": [ + "(1,32): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "embed": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "embed" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><embed></body></html>", + "noQuirksBodyHtml": "<embed>" + } + }, + { + "data": "<!doctype html><img><frameset>", + "errors": [ + "(1,30): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "img": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "img" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><img></body></html>", + "noQuirksBodyHtml": "<img>" + } + }, + { + "data": "<!doctype html><input><frameset>", + "errors": [ + "(1,32): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "input": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "input" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><input></body></html>", + "noQuirksBodyHtml": "<input>" + } + }, + { + "data": "<!doctype html><keygen><frameset>", + "errors": [ + "(1,33): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "keygen": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "keygen" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><keygen></body></html>", + "noQuirksBodyHtml": "<keygen>" + } + }, + { + "data": "<!doctype html><wbr><frameset>", + "errors": [ + "(1,30): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "wbr": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "wbr" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><wbr></body></html>", + "noQuirksBodyHtml": "<wbr>" + } + }, + { + "data": "<!doctype html><hr><frameset>", + "errors": [ + "(1,29): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "hr": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "hr" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><hr></body></html>", + "noQuirksBodyHtml": "<hr>" + } + }, + { + "data": "<!doctype html><textarea></textarea><frameset>", + "errors": [ + "(1,46): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "textarea": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "textarea" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><textarea></textarea></body></html>", + "noQuirksBodyHtml": "<textarea></textarea>" + } + }, + { + "data": "<!doctype html><xmp></xmp><frameset>", + "errors": [ + "(1,36): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "xmp": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "xmp" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><xmp></xmp></body></html>", + "noQuirksBodyHtml": "<xmp></xmp>" + } + }, + { + "data": "<!doctype html><iframe></iframe><frameset>", + "errors": [ + "(1,42): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "iframe": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "iframe" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><iframe></iframe></body></html>", + "noQuirksBodyHtml": "<iframe></iframe>" + } + }, + { + "data": "<!doctype html><select></select><frameset>", + "errors": [ + "(1,42): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>", + "noQuirksBodyHtml": "<select></select>" + } + }, + { + "data": "<!doctype html><svg></svg><frameset><frame>", + "errors": [ + "(1,36): unexpected-start-tag", + "(1,43): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true, + "frame": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset", + "children": [ + { + "tag": "frame" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset><frame></frameset></html>", + "noQuirksBodyHtml": "<svg></svg>" + } + }, + { + "data": "<!doctype html><math></math><frameset><frame>", + "errors": [ + "(1,38): unexpected-start-tag", + "(1,45): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true, + "frame": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset", + "children": [ + { + "tag": "frame" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset><frame></frameset></html>", + "noQuirksBodyHtml": "<math></math>" + } + }, + { + "data": "<!doctype html><svg><foreignObject><div> <frameset><frame>", + "errors": [ + "(1,51): unexpected-start-tag", + "(1,58): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true, + "frame": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset", + "children": [ + { + "tag": "frame" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset><frame></frameset></html>", + "noQuirksBodyHtml": "<svg><foreignObject><div> </div></foreignObject></svg>" + } + }, + { + "data": "<!doctype html><svg>a</svg><frameset><frame>", + "errors": [ + "(1,37): unexpected-start-tag", + "(1,44): unexpected-start-tag-ignored" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "a" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg>a</svg></body></html>", + "noQuirksBodyHtml": "<svg>a</svg>" + } + }, + { + "data": "<!doctype html><svg> </svg><frameset><frame>", + "errors": [ + "(1,37): unexpected-start-tag", + "(1,44): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true, + "frame": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset", + "children": [ + { + "tag": "frame" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset><frame></frameset></html>", + "noQuirksBodyHtml": "<svg> </svg>" + } + }, + { + "data": "<html>aaa<frameset></frameset>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,19): unexpected-start-tag", + "(1,30): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "aaa" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>aaa</body></html>", + "noQuirksBodyHtml": "aaa" + } + }, + { + "data": "<html> a <frameset></frameset>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,19): unexpected-start-tag", + "(1,30): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "a " + } + ] + } + ] + } + ], + "html": "<html><head></head><body>a </body></html>", + "noQuirksBodyHtml": " a " + } + }, + { + "data": "<!doctype html><div><frameset>", + "errors": [ + "(1,30): unexpected-start-tag", + "(1,30): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<div></div>" + } + }, + { + "data": "<!doctype html><div><body><frameset>", + "errors": [ + "(1,26): unexpected-start-tag", + "(1,36): unexpected-start-tag", + "(1,36): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><div></div></body></html>", + "noQuirksBodyHtml": "<div></div>" + } + }, + { + "data": "<!doctype html><p><math></p>a", + "errors": [ + "(1,28): unexpected-end-tag", + "(1,28): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "math math": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + }, + { + "text": "a" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><math></math></p>a</body></html>", + "noQuirksBodyHtml": "<p><math></math></p>a" + } + }, + { + "data": "<!doctype html><p><math><mn><span></p>a", + "errors": [ + "(1,38): unexpected-end-tag", + "(1,39): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "math math": true, + "math mn": true, + "span": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mn", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "span", + "children": [ + { + "tag": "p" + }, + { + "text": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><math><mn><span><p></p>a</span></mn></math></p></body></html>", + "noQuirksBodyHtml": "<p><math><mn><span><p></p>a</span></mn></math></p>" + } + }, + { + "data": "<!doctype html><math></html>", + "errors": [ + "(1,28): unexpected-end-tag", + "(1,28): expected-one-end-tag-but-got-another", + "(1,28): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math></math></body></html>", + "noQuirksBodyHtml": "<math></math>" + } + }, + { + "data": "<!doctype html><meta charset=\"ascii\">", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "meta": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "meta", + "attrs": [ + { + "name": "charset", + "value": "ascii" + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><meta charset=\"ascii\"></head><body></body></html>", + "noQuirksBodyHtml": "<meta charset=\"ascii\">" + } + }, + { + "data": "<!doctype html><meta http-equiv=\"content-type\" content=\"text/html;charset=ascii\">", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "meta": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "meta", + "attrs": [ + { + "name": "content", + "value": "text/html;charset=ascii" + }, + { + "name": "http-equiv", + "value": "content-type" + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><meta http-equiv=\"content-type\" content=\"text/html;charset=ascii\"></head><body></body></html>", + "noQuirksBodyHtml": "<meta http-equiv=\"content-type\" content=\"text/html;charset=ascii\">" + } + }, + { + "data": "<!doctype html><head><!--aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa--><meta charset=\"utf8\">", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "meta": true, + "body": true + }, + "doctype": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "comment": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "tag": "meta", + "attrs": [ + { + "name": "charset", + "value": "utf8" + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><!--aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa--><meta charset=\"utf8\"></head><body></body></html>", + "noQuirksBodyHtml": "<!--aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa--><meta charset=\"utf8\">" + } + }, + { + "data": "<!doctype html><html a=b><head></head><html c=d>", + "errors": [ + "(1,48): non-html-root" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "attrs": [ + { + "name": "a", + "value": "b" + }, + { + "name": "c", + "value": "d" + } + ], + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html a=\"b\" c=\"d\"><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<!doctype html><image/>", + "errors": [ + "(1,23): image-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "img": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "img" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><img></body></html>", + "noQuirksBodyHtml": "<img>" + } + }, + { + "data": "<!doctype html>a<i>b<table>c<b>d</i>e</b>f", + "errors": [ + "(1,28): foster-parenting-character", + "(1,31): foster-parenting-start-tag", + "(1,32): foster-parenting-character", + "(1,36): foster-parenting-end-tag", + "(1,36): adoption-agency-1.3", + "(1,37): foster-parenting-character", + "(1,41): foster-parenting-end-tag", + "(1,42): foster-parenting-character", + "(1,42): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "i": true, + "b": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "a" + }, + { + "tag": "i", + "children": [ + { + "text": "bc" + }, + { + "tag": "b", + "children": [ + { + "text": "de" + } + ] + }, + { + "text": "f" + }, + { + "tag": "table" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>a<i>bc<b>de</b>f<table></table></i></body></html>", + "noQuirksBodyHtml": "a<i>bc<b>de</b>f<table></table></i>" + } + }, + { + "data": "<!doctype html><table><i>a<b>b<div>c<a>d</i>e</b>f", + "errors": [ + "(1,25): foster-parenting-start-tag", + "(1,26): foster-parenting-character", + "(1,29): foster-parenting-start-tag", + "(1,30): foster-parenting-character", + "(1,35): foster-parenting-start-tag", + "(1,36): foster-parenting-character", + "(1,39): foster-parenting-start-tag", + "(1,40): foster-parenting-character", + "(1,44): foster-parenting-end-tag", + "(1,44): adoption-agency-1.3", + "(1,44): adoption-agency-1.3", + "(1,45): foster-parenting-character", + "(1,49): foster-parenting-end-tag", + "(1,49): adoption-agency-1.3", + "(1,49): adoption-agency-1.3", + "(1,50): foster-parenting-character", + "(1,50): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "i": true, + "b": true, + "div": true, + "a": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "i", + "children": [ + { + "text": "a" + }, + { + "tag": "b", + "children": [ + { + "text": "b" + } + ] + } + ] + }, + { + "tag": "b" + }, + { + "tag": "div", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "i", + "children": [ + { + "text": "c" + }, + { + "tag": "a", + "children": [ + { + "text": "d" + } + ] + } + ] + }, + { + "tag": "a", + "children": [ + { + "text": "e" + } + ] + } + ] + }, + { + "tag": "a", + "children": [ + { + "text": "f" + } + ] + } + ] + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><i>a<b>b</b></i><b></b><div><b><i>c<a>d</a></i><a>e</a></b><a>f</a></div><table></table></body></html>", + "noQuirksBodyHtml": "<i>a<b>b</b></i><b></b><div><b><i>c<a>d</a></i><a>e</a></b><a>f</a></div><table></table>" + } + }, + { + "data": "<!doctype html><i>a<b>b<div>c<a>d</i>e</b>f", + "errors": [ + "(1,37): adoption-agency-1.3", + "(1,37): adoption-agency-1.3", + "(1,42): adoption-agency-1.3", + "(1,42): adoption-agency-1.3", + "(1,43): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "i": true, + "b": true, + "div": true, + "a": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "i", + "children": [ + { + "text": "a" + }, + { + "tag": "b", + "children": [ + { + "text": "b" + } + ] + } + ] + }, + { + "tag": "b" + }, + { + "tag": "div", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "i", + "children": [ + { + "text": "c" + }, + { + "tag": "a", + "children": [ + { + "text": "d" + } + ] + } + ] + }, + { + "tag": "a", + "children": [ + { + "text": "e" + } + ] + } + ] + }, + { + "tag": "a", + "children": [ + { + "text": "f" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><i>a<b>b</b></i><b></b><div><b><i>c<a>d</a></i><a>e</a></b><a>f</a></div></body></html>", + "noQuirksBodyHtml": "<i>a<b>b</b></i><b></b><div><b><i>c<a>d</a></i><a>e</a></b><a>f</a></div>" + } + }, + { + "data": "<!doctype html><table><i>a<b>b<div>c</i>", + "errors": [ + "(1,25): foster-parenting-start-tag", + "(1,26): foster-parenting-character", + "(1,29): foster-parenting-start-tag", + "(1,30): foster-parenting-character", + "(1,35): foster-parenting-start-tag", + "(1,36): foster-parenting-character", + "(1,40): foster-parenting-end-tag", + "(1,40): adoption-agency-1.3", + "(1,40): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "i": true, + "b": true, + "div": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "i", + "children": [ + { + "text": "a" + }, + { + "tag": "b", + "children": [ + { + "text": "b" + } + ] + } + ] + }, + { + "tag": "b", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "i", + "children": [ + { + "text": "c" + } + ] + } + ] + } + ] + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><i>a<b>b</b></i><b><div><i>c</i></div></b><table></table></body></html>", + "noQuirksBodyHtml": "<i>a<b>b</b></i><b><div><i>c</i></div></b><table></table>" + } + }, + { + "data": "<!doctype html><table><i>a<b>b<div>c<a>d</i>e</b>f", + "errors": [ + "(1,25): foster-parenting-start-tag", + "(1,26): foster-parenting-character", + "(1,29): foster-parenting-start-tag", + "(1,30): foster-parenting-character", + "(1,35): foster-parenting-start-tag", + "(1,36): foster-parenting-character", + "(1,39): foster-parenting-start-tag", + "(1,40): foster-parenting-character", + "(1,44): foster-parenting-end-tag", + "(1,44): adoption-agency-1.3", + "(1,44): adoption-agency-1.3", + "(1,45): foster-parenting-character", + "(1,49): foster-parenting-end-tag", + "(1,44): adoption-agency-1.3", + "(1,44): adoption-agency-1.3", + "(1,50): foster-parenting-character", + "(1,50): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "i": true, + "b": true, + "div": true, + "a": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "i", + "children": [ + { + "text": "a" + }, + { + "tag": "b", + "children": [ + { + "text": "b" + } + ] + } + ] + }, + { + "tag": "b" + }, + { + "tag": "div", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "i", + "children": [ + { + "text": "c" + }, + { + "tag": "a", + "children": [ + { + "text": "d" + } + ] + } + ] + }, + { + "tag": "a", + "children": [ + { + "text": "e" + } + ] + } + ] + }, + { + "tag": "a", + "children": [ + { + "text": "f" + } + ] + } + ] + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><i>a<b>b</b></i><b></b><div><b><i>c<a>d</a></i><a>e</a></b><a>f</a></div><table></table></body></html>", + "noQuirksBodyHtml": "<i>a<b>b</b></i><b></b><div><b><i>c<a>d</a></i><a>e</a></b><a>f</a></div><table></table>" + } + }, + { + "data": "<!doctype html><table><i>a<div>b<tr>c<b>d</i>e", + "errors": [ + "(1,25): foster-parenting-start-tag", + "(1,26): foster-parenting-character", + "(1,31): foster-parenting-start-tag", + "(1,32): foster-parenting-character", + "(1,37): foster-parenting-character", + "(1,40): foster-parenting-start-tag", + "(1,41): foster-parenting-character", + "(1,45): foster-parenting-end-tag", + "(1,45): adoption-agency-1.3", + "(1,46): foster-parenting-character", + "(1,46): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "i": true, + "div": true, + "b": true, + "table": true, + "tbody": true, + "tr": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "i", + "children": [ + { + "text": "a" + }, + { + "tag": "div", + "children": [ + { + "text": "b" + } + ] + } + ] + }, + { + "tag": "i", + "children": [ + { + "text": "c" + }, + { + "tag": "b", + "children": [ + { + "text": "d" + } + ] + } + ] + }, + { + "tag": "b", + "children": [ + { + "text": "e" + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><i>a<div>b</div></i><i>c<b>d</b></i><b>e</b><table><tbody><tr></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<i>a<div>b</div></i><i>c<b>d</b></i><b>e</b><table><tbody><tr></tr></tbody></table>" + } + }, + { + "data": "<!doctype html><table><td><table><i>a<div>b<b>c</i>d", + "errors": [ + "(1,26): unexpected-cell-in-table-body", + "(1,36): foster-parenting-start-tag", + "(1,37): foster-parenting-character", + "(1,42): foster-parenting-start-tag", + "(1,43): foster-parenting-character", + "(1,46): foster-parenting-start-tag", + "(1,47): foster-parenting-character", + "(1,51): foster-parenting-end-tag", + "(1,51): adoption-agency-1.3", + "(1,51): adoption-agency-1.3", + "(1,52): foster-parenting-character", + "(1,52): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "i": true, + "div": true, + "b": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "i", + "children": [ + { + "text": "a" + } + ] + }, + { + "tag": "div", + "children": [ + { + "tag": "i", + "children": [ + { + "text": "b" + }, + { + "tag": "b", + "children": [ + { + "text": "c" + } + ] + } + ] + }, + { + "tag": "b", + "children": [ + { + "text": "d" + } + ] + } + ] + }, + { + "tag": "table" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><i>a</i><div><i>b<b>c</b></i><b>d</b></div><table></table></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><i>a</i><div><i>b<b>c</b></i><b>d</b></div><table></table></td></tr></tbody></table>" + } + }, + { + "data": "<!doctype html><body><bgsound>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "bgsound": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "bgsound" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><bgsound></body></html>", + "noQuirksBodyHtml": "<bgsound>" + } + }, + { + "data": "<!doctype html><body><basefont>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "basefont": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "basefont" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><basefont></body></html>", + "noQuirksBodyHtml": "<basefont>" + } + }, + { + "data": "<!doctype html><a><b></a><basefont>", + "errors": [ + "(1,25): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "b": true, + "basefont": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "b" + } + ] + }, + { + "tag": "basefont" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><a><b></b></a><basefont></body></html>", + "noQuirksBodyHtml": "<a><b></b></a><basefont>" + } + }, + { + "data": "<!doctype html><a><b></a><bgsound>", + "errors": [ + "(1,25): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "b": true, + "bgsound": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "b" + } + ] + }, + { + "tag": "bgsound" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><a><b></b></a><bgsound></body></html>", + "noQuirksBodyHtml": "<a><b></b></a><bgsound>" + } + }, + { + "data": "<!doctype html><figcaption><article></figcaption>a", + "errors": [ + "(1,49): end-tag-too-early" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "figcaption": true, + "article": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "figcaption", + "children": [ + { + "tag": "article" + } + ] + }, + { + "text": "a" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><figcaption><article></article></figcaption>a</body></html>", + "noQuirksBodyHtml": "<figcaption><article></article></figcaption>a" + } + }, + { + "data": "<!doctype html><summary><article></summary>a", + "errors": [ + "(1,43): end-tag-too-early" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "summary": true, + "article": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "summary", + "children": [ + { + "tag": "article" + } + ] + }, + { + "text": "a" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><summary><article></article></summary>a</body></html>", + "noQuirksBodyHtml": "<summary><article></article></summary>a" + } + }, + { + "data": "<!doctype html><p><a><plaintext>b", + "errors": [ + "(1,32): unexpected-end-tag", + "(1,33): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "a": true, + "plaintext": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "a" + } + ] + }, + { + "tag": "plaintext", + "children": [ + { + "tag": "a", + "children": [ + { + "text": "b" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><a></a></p><plaintext><a>b</a></plaintext></body></html>", + "noQuirksBodyHtml": "<p><a></a></p><plaintext><a>b</a></plaintext>" + } + }, + { + "data": "<!DOCTYPE html><div>a<a></div>b<p>c</p>d", + "errors": [ + "(1,30): end-tag-too-early", + "(1,40): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "a": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "a" + }, + { + "tag": "a" + } + ] + }, + { + "tag": "a", + "children": [ + { + "text": "b" + }, + { + "tag": "p", + "children": [ + { + "text": "c" + } + ] + }, + { + "text": "d" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><div>a<a></a></div><a>b<p>c</p>d</a></body></html>", + "noQuirksBodyHtml": "<div>a<a></a></div><a>b<p>c</p>d</a>" + } + } + ], + "tests2.dat": [ + { + "data": "<!DOCTYPE html>Test", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Test" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>Test</body></html>", + "noQuirksBodyHtml": "Test" + } + }, + { + "data": "<textarea>test</div>test", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(1,24): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "textarea": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "textarea", + "children": [ + { + "text": "test</div>test", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><textarea>test&lt;/div&gt;test</textarea></body></html>", + "noQuirksBodyHtml": "<textarea>test&lt;/div&gt;test</textarea>" + } + }, + { + "data": "<table><td>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,11): unexpected-cell-in-table-body", + "(1,11): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><td></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td></td></tr></tbody></table>" + } + }, + { + "data": "<table><td>test</tbody></table>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,11): unexpected-cell-in-table-body" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "text": "test" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><td>test</td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td>test</td></tr></tbody></table>" + } + }, + { + "data": "<frame>test", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,7): unexpected-start-tag-ignored" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "test" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>test</body></html>", + "noQuirksBodyHtml": "test" + } + }, + { + "data": "<!DOCTYPE html><frameset>test", + "errors": [ + "(1,29): unexpected-char-in-frameset", + "(1,29): unexpected-char-in-frameset", + "(1,29): unexpected-char-in-frameset", + "(1,29): unexpected-char-in-frameset", + "(1,29): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "test" + } + }, + { + "data": "<!DOCTYPE html><frameset> te st", + "errors": [ + "(1,29): unexpected-char-in-frameset", + "(1,29): unexpected-char-in-frameset", + "(1,29): unexpected-char-in-frameset", + "(1,29): unexpected-char-in-frameset", + "(1,29): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset", + "children": [ + { + "text": " " + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset> </frameset></html>", + "noQuirksBodyHtml": " te st" + } + }, + { + "data": "<!DOCTYPE html><frameset></frameset> te st", + "errors": [ + "(1,29): unexpected-char-after-frameset", + "(1,29): unexpected-char-after-frameset", + "(1,29): unexpected-char-after-frameset", + "(1,29): unexpected-char-after-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + }, + { + "text": " " + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset> </html>", + "noQuirksBodyHtml": " te st" + } + }, + { + "data": "<!DOCTYPE html><frameset><!DOCTYPE html>", + "errors": [ + "(1,40): unexpected-doctype", + "(1,40): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<!DOCTYPE html><font><p><b>test</font>", + "errors": [ + "(1,38): adoption-agency-1.3", + "(1,38): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "font": true, + "p": true, + "b": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "font" + }, + { + "tag": "p", + "children": [ + { + "tag": "font", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "test" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><font></font><p><font><b>test</b></font></p></body></html>", + "noQuirksBodyHtml": "<font></font><p><font><b>test</b></font></p>" + } + }, + { + "data": "<!DOCTYPE html><dt><div><dd>", + "errors": [ + "(1,28): end-tag-too-early" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "dt": true, + "div": true, + "dd": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "dt", + "children": [ + { + "tag": "div" + } + ] + }, + { + "tag": "dd" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><dt><div></div></dt><dd></dd></body></html>", + "noQuirksBodyHtml": "<dt><div></div></dt><dd></dd>" + } + }, + { + "data": "<script></x", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,11): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "</x", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script></x</script></head><body></body></html>", + "noQuirksBodyHtml": "<script></x</script>" + } + }, + { + "data": "<table><plaintext><td>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,18): unexpected-start-tag-implies-table-voodoo", + "(1,22): foster-parenting-character-in-table", + "(1,22): foster-parenting-character-in-table", + "(1,22): foster-parenting-character-in-table", + "(1,22): foster-parenting-character-in-table", + "(1,22): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "plaintext": true, + "table": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "plaintext", + "children": [ + { + "text": "<td>", + "no_escape": true + } + ] + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><plaintext><td></plaintext><table></table></body></html>", + "noQuirksBodyHtml": "<plaintext><td></plaintext><table></table>" + } + }, + { + "data": "<plaintext></plaintext>", + "errors": [ + "(1,11): expected-doctype-but-got-start-tag", + "(1,23): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "plaintext": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "plaintext", + "children": [ + { + "text": "</plaintext>", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><plaintext></plaintext></plaintext></body></html>", + "noQuirksBodyHtml": "<plaintext></plaintext></plaintext>" + } + }, + { + "data": "<!DOCTYPE html><table><tr>TEST", + "errors": [ + "(1,30): foster-parenting-character-in-table", + "(1,30): foster-parenting-character-in-table", + "(1,30): foster-parenting-character-in-table", + "(1,30): foster-parenting-character-in-table", + "(1,30): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "TEST" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>TEST<table><tbody><tr></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "TEST<table><tbody><tr></tr></tbody></table>" + } + }, + { + "data": "<!DOCTYPE html><body t1=1><body t2=2><body t3=3 t4=4>", + "errors": [ + "(1,37): unexpected-start-tag", + "(1,53): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "attrs": [ + { + "name": "t1", + "value": "1" + }, + { + "name": "t2", + "value": "2" + }, + { + "name": "t3", + "value": "3" + }, + { + "name": "t4", + "value": "4" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body t1=\"1\" t2=\"2\" t3=\"3\" t4=\"4\"></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "</b test", + "errors": [ + "(1,8): eof-in-attribute-name", + "(1,8): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<!DOCTYPE html></b test<b &=&amp>X", + "errors": [ + "(1,24): invalid-character-in-attribute-name", + "(1,32): named-entity-without-semicolon", + "(1,33): attributes-in-end-tag", + "(1,33): unexpected-end-tag-before-html" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>X</body></html>", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "<!doctypehtml><scrIPt type=text/x-foobar;baz>X</SCRipt", + "errors": [ + "(1,9): need-space-after-doctype", + "(1,54): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "attrs": [ + { + "name": "type", + "value": "text/x-foobar;baz" + } + ], + "children": [ + { + "text": "X</SCRipt", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script type=\"text/x-foobar;baz\">X</SCRipt</script></head><body></body></html>", + "noQuirksBodyHtml": "<script type=\"text/x-foobar;baz\">X</SCRipt</script>" + } + }, + { + "data": "&", + "errors": [ + "(1,1): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "&", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body>&amp;</body></html>", + "noQuirksBodyHtml": "&amp;" + } + }, + { + "data": "&#", + "errors": [ + "(1,2): expected-numeric-entity", + "(1,2): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "&#", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body>&amp;#</body></html>", + "noQuirksBodyHtml": "&amp;#" + } + }, + { + "data": "&#X", + "errors": [ + "(1,3): expected-numeric-entity", + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "&#X", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body>&amp;#X</body></html>", + "noQuirksBodyHtml": "&amp;#X" + } + }, + { + "data": "&#x", + "errors": [ + "(1,3): expected-numeric-entity", + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "&#x", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body>&amp;#x</body></html>", + "noQuirksBodyHtml": "&amp;#x" + } + }, + { + "data": "&#45", + "errors": [ + "(1,4): numeric-entity-without-semicolon", + "(1,4): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "-" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>-</body></html>", + "noQuirksBodyHtml": "-" + } + }, + { + "data": "&x-test", + "errors": [ + "(1,2): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "&x-test", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body>&amp;x-test</body></html>", + "noQuirksBodyHtml": "&amp;x-test" + } + }, + { + "data": "<!doctypehtml><p><li>", + "errors": [ + "(1,9): need-space-after-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "li": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p" + }, + { + "tag": "li" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p></p><li></li></body></html>", + "noQuirksBodyHtml": "<p></p><li></li>" + } + }, + { + "data": "<!doctypehtml><p><dt>", + "errors": [ + "(1,9): need-space-after-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "dt": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p" + }, + { + "tag": "dt" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p></p><dt></dt></body></html>", + "noQuirksBodyHtml": "<p></p><dt></dt>" + } + }, + { + "data": "<!doctypehtml><p><dd>", + "errors": [ + "(1,9): need-space-after-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "dd": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p" + }, + { + "tag": "dd" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p></p><dd></dd></body></html>", + "noQuirksBodyHtml": "<p></p><dd></dd>" + } + }, + { + "data": "<!doctypehtml><p><form>", + "errors": [ + "(1,9): need-space-after-doctype", + "(1,23): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "form": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p" + }, + { + "tag": "form" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p></p><form></form></body></html>", + "noQuirksBodyHtml": "<p></p><form></form>" + } + }, + { + "data": "<!DOCTYPE html><p></P>X", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p" + }, + { + "text": "X" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p></p>X</body></html>", + "noQuirksBodyHtml": "<p></p>X" + } + }, + { + "data": "&AMP", + "errors": [ + "(1,4): named-entity-without-semicolon", + "(1,4): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "&", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body>&amp;</body></html>", + "noQuirksBodyHtml": "&amp;" + } + }, + { + "data": "&AMp;", + "errors": [ + "(1,3): expected-named-entity", + "(1,3): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "&AMp;", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body>&amp;AMp;</body></html>", + "noQuirksBodyHtml": "&amp;AMp;" + } + }, + { + "data": "<!DOCTYPE html><html><head></head><body><thisISasillyTESTelementNameToMakeSureCrazyTagNamesArePARSEDcorrectLY>", + "errors": [ + "(1,110): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "thisisasillytestelementnametomakesurecrazytagnamesareparsedcorrectly": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "thisisasillytestelementnametomakesurecrazytagnamesareparsedcorrectly" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><thisisasillytestelementnametomakesurecrazytagnamesareparsedcorrectly></thisisasillytestelementnametomakesurecrazytagnamesareparsedcorrectly></body></html>", + "noQuirksBodyHtml": "<thisisasillytestelementnametomakesurecrazytagnamesareparsedcorrectly></thisisasillytestelementnametomakesurecrazytagnamesareparsedcorrectly>" + } + }, + { + "data": "<!DOCTYPE html>X</body>X", + "errors": [ + "(1,24): unexpected-char-after-body" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "XX" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>XX</body></html>", + "noQuirksBodyHtml": "XX" + } + }, + { + "data": "<!DOCTYPE html><!-- X", + "errors": [ + "(1,21): eof-in-comment" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "comment": " X" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><!-- X--><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!-- X-->" + } + }, + { + "data": "<!DOCTYPE html><table><caption>test TEST</caption><td>test", + "errors": [ + "(1,54): unexpected-cell-in-table-body", + "(1,58): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "caption": true, + "tbody": true, + "tr": true, + "td": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "caption", + "children": [ + { + "text": "test TEST" + } + ] + }, + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "text": "test" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><caption>test TEST</caption><tbody><tr><td>test</td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><caption>test TEST</caption><tbody><tr><td>test</td></tr></tbody></table>" + } + }, + { + "data": "<!DOCTYPE html><select><option><optgroup>", + "errors": [ + "(1,41): eof-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "option": true, + "optgroup": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "option" + }, + { + "tag": "optgroup" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select><option></option><optgroup></optgroup></select></body></html>", + "noQuirksBodyHtml": "<select><option></option><optgroup></optgroup></select>" + } + }, + { + "data": "<!DOCTYPE html><select><optgroup><option></optgroup><option><select><option>", + "errors": [ + "(1,68): unexpected-select-in-select", + "(1,76): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "optgroup": true, + "option": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "optgroup", + "children": [ + { + "tag": "option" + } + ] + }, + { + "tag": "option" + } + ] + }, + { + "tag": "option" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select><optgroup><option></option></optgroup><option></option></select><option></option></body></html>", + "noQuirksBodyHtml": "<select><optgroup><option></option></optgroup><option></option></select><option></option>" + } + }, + { + "data": "<!DOCTYPE html><select><optgroup><option><optgroup>", + "errors": [ + "(1,51): eof-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "optgroup": true, + "option": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "optgroup", + "children": [ + { + "tag": "option" + } + ] + }, + { + "tag": "optgroup" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select><optgroup><option></option></optgroup><optgroup></optgroup></select></body></html>", + "noQuirksBodyHtml": "<select><optgroup><option></option></optgroup><optgroup></optgroup></select>" + } + }, + { + "data": "<!DOCTYPE html><datalist><option>foo</datalist>bar", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "datalist": true, + "option": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "datalist", + "children": [ + { + "tag": "option", + "children": [ + { + "text": "foo" + } + ] + } + ] + }, + { + "text": "bar" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><datalist><option>foo</option></datalist>bar</body></html>", + "noQuirksBodyHtml": "<datalist><option>foo</option></datalist>bar" + } + }, + { + "data": "<!DOCTYPE html><font><input><input></font>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "font": true, + "input": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "font", + "children": [ + { + "tag": "input" + }, + { + "tag": "input" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><font><input><input></font></body></html>", + "noQuirksBodyHtml": "<font><input><input></font>" + } + }, + { + "data": "<!DOCTYPE html><!-- XXX - XXX -->", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "comment": " XXX - XXX " + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><!-- XXX - XXX --><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!-- XXX - XXX -->" + } + }, + { + "data": "<!DOCTYPE html><!-- XXX - XXX", + "errors": [ + "(1,29): eof-in-comment" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "comment": " XXX - XXX" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><!-- XXX - XXX--><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!-- XXX - XXX-->" + } + }, + { + "data": "<!DOCTYPE html><!-- XXX - XXX - XXX -->", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "comment": " XXX - XXX - XXX " + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><!-- XXX - XXX - XXX --><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!-- XXX - XXX - XXX -->" + } + }, + { + "data": "<isindex test=x name=x>", + "errors": [ + "(1,23): expected-doctype-but-got-start-tag", + "(1,23): deprecated-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "form": true, + "hr": true, + "label": true, + "input": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "form", + "children": [ + { + "tag": "hr" + }, + { + "tag": "label", + "children": [ + { + "text": "This is a searchable index. Enter search keywords: " + }, + { + "tag": "input", + "attrs": [ + { + "name": "name", + "value": "isindex" + }, + { + "name": "test", + "value": "x" + } + ] + } + ] + }, + { + "tag": "hr" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><form><hr><label>This is a searchable index. Enter search keywords: <input name=\"isindex\" test=\"x\"></label><hr></form></body></html>", + "noQuirksBodyHtml": "<form><hr><label>This is a searchable index. Enter search keywords: <input name=\"isindex\" test=\"x\"></label><hr></form>" + } + }, + { + "data": "test\ntest", + "errors": [ + "(2,4): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "test\ntest" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>test\ntest</body></html>", + "noQuirksBodyHtml": "test\ntest" + } + }, + { + "data": "<!DOCTYPE html><body><title>test</body></title>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "title": true + }, + "doctype": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "test</body>", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><title>test&lt;/body&gt;</title></body></html>", + "noQuirksBodyHtml": "<title>test&lt;/body&gt;</title>" + } + }, + { + "data": "<!DOCTYPE html><body><title>X</title><meta name=z><link rel=foo><style>\nx { content:\"</style\" } </style>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "title": true, + "meta": true, + "link": true, + "style": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "X" + } + ] + }, + { + "tag": "meta", + "attrs": [ + { + "name": "name", + "value": "z" + } + ] + }, + { + "tag": "link", + "attrs": [ + { + "name": "rel", + "value": "foo" + } + ] + }, + { + "tag": "style", + "children": [ + { + "text": "\nx { content:\"</style\" } ", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><title>X</title><meta name=\"z\"><link rel=\"foo\"><style>\nx { content:\"</style\" } </style></body></html>", + "noQuirksBodyHtml": "<title>X</title><meta name=\"z\"><link rel=\"foo\"><style>\nx { content:\"</style\" } </style>" + } + }, + { + "data": "<!DOCTYPE html><select><optgroup></optgroup></select>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "optgroup": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "optgroup" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select><optgroup></optgroup></select></body></html>", + "noQuirksBodyHtml": "<select><optgroup></optgroup></select>" + } + }, + { + "data": " \n ", + "errors": [ + "(2,1): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": " \n " + } + }, + { + "data": "<!DOCTYPE html> <html>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body></body></html>", + "noQuirksBodyHtml": " " + } + }, + { + "data": "<!DOCTYPE html><script>\n</script> <title>x</title> </head>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "title": true, + "body": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": "\n", + "no_escape": true + } + ] + }, + { + "text": " " + }, + { + "tag": "title", + "children": [ + { + "text": "x" + } + ] + }, + { + "text": " " + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><script>\n</script> <title>x</title> </head><body></body></html>", + "noQuirksBodyHtml": "<script>\n</script> <title>x</title> " + } + }, + { + "data": "<!DOCTYPE html><html><body><html id=x>", + "errors": [ + "(1,38): non-html-root" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "attrs": [ + { + "name": "id", + "value": "x" + } + ], + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html id=\"x\"><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<!DOCTYPE html>X</body><html id=\"x\">", + "errors": [ + "(1,36): non-html-root" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "attrs": [ + { + "name": "id", + "value": "x" + } + ], + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html id=\"x\"><head></head><body>X</body></html>", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "<!DOCTYPE html><head><html id=x>", + "errors": [ + "(1,32): non-html-root" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "attrs": [ + { + "name": "id", + "value": "x" + } + ], + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html id=\"x\"><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<!DOCTYPE html>X</html>X", + "errors": [ + "(1,24): expected-eof-but-got-char" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "XX" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>XX</body></html>", + "noQuirksBodyHtml": "XX" + } + }, + { + "data": "<!DOCTYPE html>X</html> ", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "X " + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>X </body></html>", + "noQuirksBodyHtml": "X " + } + }, + { + "data": "<!DOCTYPE html>X</html><p>X", + "errors": [ + "(1,26): expected-eof-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "X" + }, + { + "tag": "p", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>X<p>X</p></body></html>", + "noQuirksBodyHtml": "X<p>X</p>" + } + }, + { + "data": "<!DOCTYPE html>X<p/x/y/z>", + "errors": [ + "(1,19): unexpected-character-after-solidus-in-tag", + "(1,21): unexpected-character-after-solidus-in-tag", + "(1,23): unexpected-character-after-solidus-in-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "X" + }, + { + "tag": "p", + "attrs": [ + { + "name": "x", + "value": "" + }, + { + "name": "y", + "value": "" + }, + { + "name": "z", + "value": "" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>X<p x=\"\" y=\"\" z=\"\"></p></body></html>", + "noQuirksBodyHtml": "X<p x=\"\" y=\"\" z=\"\"></p>" + } + }, + { + "data": "<!DOCTYPE html><!--x--", + "errors": [ + "(1,22): eof-in-comment-double-dash" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "comment": "x" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><!--x--><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!--x-->" + } + }, + { + "data": "<!DOCTYPE html><table><tr><td></p></table>", + "errors": [ + "(1,34): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "p" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><p></p></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><p></p></td></tr></tbody></table>" + } + }, + { + "data": "<!DOCTYPE <!DOCTYPE HTML>><!--<!--x-->-->", + "errors": [ + "(1,20): expected-space-or-right-bracket-in-doctype", + "(1,25): unknown-doctype", + "(1,35): unexpected-char-in-comment" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true, + "escaped": true, + "comment": true + }, + "tree": [ + { + "doctype": "<!doctype" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": ">", + "escaped": true + }, + { + "comment": "<!--x" + }, + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<!DOCTYPE <!doctype><html><head></head><body>&gt;<!--<!--x-->--&gt;</body></html>", + "noQuirksBodyHtml": "&gt;<!--<!--x-->--&gt;" + } + }, + { + "data": "<!doctype html><div><form></form><div></div></div>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "form": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "form" + }, + { + "tag": "div" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><div><form></form><div></div></div></body></html>", + "noQuirksBodyHtml": "<div><form></form><div></div></div>" + } + } + ], + "tests20.dat": [ + { + "data": "<!doctype html><p><button><button>", + "errors": [ + "(1,34): unexpected-start-tag-implies-end-tag", + "(1,34): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button" + }, + { + "tag": "button" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button></button><button></button></p></body></html>", + "noQuirksBodyHtml": "<p><button></button><button></button></p>" + } + }, + { + "data": "<!doctype html><p><button><address>", + "errors": [ + "(1,35): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true, + "address": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "address" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button><address></address></button></p></body></html>", + "noQuirksBodyHtml": "<p><button><address></address></button></p>" + } + }, + { + "data": "<!doctype html><p><button><blockquote>", + "errors": [ + "(1,38): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true, + "blockquote": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "blockquote" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button><blockquote></blockquote></button></p></body></html>", + "noQuirksBodyHtml": "<p><button><blockquote></blockquote></button></p>" + } + }, + { + "data": "<!doctype html><p><button><menu>", + "errors": [ + "(1,32): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true, + "menu": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "menu" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button><menu></menu></button></p></body></html>", + "noQuirksBodyHtml": "<p><button><menu></menu></button></p>" + } + }, + { + "data": "<!doctype html><p><button><p>", + "errors": [ + "(1,29): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "p" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button><p></p></button></p></body></html>", + "noQuirksBodyHtml": "<p><button><p></p></button></p>" + } + }, + { + "data": "<!doctype html><p><button><ul>", + "errors": [ + "(1,30): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true, + "ul": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "ul" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button><ul></ul></button></p></body></html>", + "noQuirksBodyHtml": "<p><button><ul></ul></button></p>" + } + }, + { + "data": "<!doctype html><p><button><h1>", + "errors": [ + "(1,30): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true, + "h1": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "h1" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button><h1></h1></button></p></body></html>", + "noQuirksBodyHtml": "<p><button><h1></h1></button></p>" + } + }, + { + "data": "<!doctype html><p><button><h6>", + "errors": [ + "(1,30): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true, + "h6": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "h6" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button><h6></h6></button></p></body></html>", + "noQuirksBodyHtml": "<p><button><h6></h6></button></p>" + } + }, + { + "data": "<!doctype html><p><button><listing>", + "errors": [ + "(1,35): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true, + "listing": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "listing" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button><listing></listing></button></p></body></html>", + "noQuirksBodyHtml": "<p><button><listing></listing></button></p>" + } + }, + { + "data": "<!doctype html><p><button><pre>", + "errors": [ + "(1,31): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true, + "pre": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "pre" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button><pre></pre></button></p></body></html>", + "noQuirksBodyHtml": "<p><button><pre></pre></button></p>" + } + }, + { + "data": "<!doctype html><p><button><form>", + "errors": [ + "(1,32): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true, + "form": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "form" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button><form></form></button></p></body></html>", + "noQuirksBodyHtml": "<p><button><form></form></button></p>" + } + }, + { + "data": "<!doctype html><p><button><li>", + "errors": [ + "(1,30): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true, + "li": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "li" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button><li></li></button></p></body></html>", + "noQuirksBodyHtml": "<p><button><li></li></button></p>" + } + }, + { + "data": "<!doctype html><p><button><dd>", + "errors": [ + "(1,30): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true, + "dd": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "dd" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button><dd></dd></button></p></body></html>", + "noQuirksBodyHtml": "<p><button><dd></dd></button></p>" + } + }, + { + "data": "<!doctype html><p><button><dt>", + "errors": [ + "(1,30): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true, + "dt": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "dt" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button><dt></dt></button></p></body></html>", + "noQuirksBodyHtml": "<p><button><dt></dt></button></p>" + } + }, + { + "data": "<!doctype html><p><button><plaintext>", + "errors": [ + "(1,37): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true, + "plaintext": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "plaintext" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button><plaintext></plaintext></button></p></body></html>", + "noQuirksBodyHtml": "<p><button><plaintext></plaintext></button></p>" + } + }, + { + "data": "<!doctype html><p><button><table>", + "errors": [ + "(1,33): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "table" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button><table></table></button></p></body></html>", + "noQuirksBodyHtml": "<p><button><table></table></button></p>" + } + }, + { + "data": "<!doctype html><p><button><hr>", + "errors": [ + "(1,30): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true, + "hr": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "hr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button><hr></button></p></body></html>", + "noQuirksBodyHtml": "<p><button><hr></button></p>" + } + }, + { + "data": "<!doctype html><p><button><xmp>", + "errors": [ + "(1,31): expected-named-closing-tag-but-got-eof", + "(1,31): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true, + "xmp": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "xmp" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button><xmp></xmp></button></p></body></html>", + "noQuirksBodyHtml": "<p><button><xmp></xmp></button></p>" + } + }, + { + "data": "<!doctype html><p><button></p>", + "errors": [ + "(1,30): unexpected-end-tag", + "(1,30): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "button": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "p" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><button><p></p></button></p></body></html>", + "noQuirksBodyHtml": "<p><button><p></p></button></p>" + } + }, + { + "data": "<!doctype html><address><button></address>a", + "errors": [ + "(1,42): end-tag-too-early" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "address": true, + "button": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "address", + "children": [ + { + "tag": "button" + } + ] + }, + { + "text": "a" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><address><button></button></address>a</body></html>", + "noQuirksBodyHtml": "<address><button></button></address>a" + } + }, + { + "data": "<!doctype html><address><button></address>a", + "errors": [ + "(1,42): end-tag-too-early" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "address": true, + "button": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "address", + "children": [ + { + "tag": "button" + } + ] + }, + { + "text": "a" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><address><button></button></address>a</body></html>", + "noQuirksBodyHtml": "<address><button></button></address>a" + } + }, + { + "data": "<p><table></p>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,14): unexpected-end-tag-implies-table-voodoo", + "(1,14): unexpected-end-tag", + "(1,14): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "table": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "p" + }, + { + "tag": "table" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p><p></p><table></table></p></body></html>", + "noQuirksBodyHtml": "<p></p><p></p><table></table>" + } + }, + { + "data": "<!doctype html><svg>", + "errors": [ + "(1,20): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg></svg></body></html>", + "noQuirksBodyHtml": "<svg></svg>" + } + }, + { + "data": "<!doctype html><p><figcaption>", + "errors": [ + "(1,30): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "figcaption": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p" + }, + { + "tag": "figcaption" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p></p><figcaption></figcaption></body></html>", + "noQuirksBodyHtml": "<p></p><figcaption></figcaption>" + } + }, + { + "data": "<!doctype html><p><summary>", + "errors": [ + "(1,27): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "summary": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p" + }, + { + "tag": "summary" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p></p><summary></summary></body></html>", + "noQuirksBodyHtml": "<p></p><summary></summary>" + } + }, + { + "data": "<!doctype html><form><table><form>", + "errors": [ + "(1,34): unexpected-form-in-table", + "(1,34): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "form": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "form", + "children": [ + { + "tag": "table" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><form><table></table></form></body></html>", + "noQuirksBodyHtml": "<form><table></table></form>" + } + }, + { + "data": "<!doctype html><table><form><form>", + "errors": [ + "(1,28): unexpected-form-in-table", + "(1,34): unexpected-form-in-table", + "(1,34): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "form": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "form" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><form></form></table></body></html>", + "noQuirksBodyHtml": "<table><form></form></table>" + } + }, + { + "data": "<!doctype html><table><form></table><form>", + "errors": [ + "(1,28): unexpected-form-in-table", + "(1,42): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "form": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "form" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><form></form></table></body></html>", + "noQuirksBodyHtml": "<table><form></form></table>" + } + }, + { + "data": "<!doctype html><svg><foreignObject><p>", + "errors": [ + "(1,38): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg foreignObject": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "p" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg><foreignObject><p></p></foreignObject></svg></body></html>", + "noQuirksBodyHtml": "<svg><foreignObject><p></p></foreignObject></svg>" + } + }, + { + "data": "<!doctype html><svg><title>abc", + "errors": [ + "(1,30): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg title": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "title", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "abc" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg><title>abc</title></svg></body></html>", + "noQuirksBodyHtml": "<svg><title>abc</title></svg>" + } + }, + { + "data": "<option><span><option>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,22): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "option": true, + "span": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "option", + "children": [ + { + "tag": "span", + "children": [ + { + "tag": "option" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><option><span><option></option></span></option></body></html>", + "noQuirksBodyHtml": "<option><span><option></option></span></option>" + } + }, + { + "data": "<option><option>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,16): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "option": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "option" + }, + { + "tag": "option" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><option></option><option></option></body></html>", + "noQuirksBodyHtml": "<option></option><option></option>" + } + }, + { + "data": "<math><annotation-xml><div>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,27): unexpected-html-element-in-foreign-content", + "(1,27): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math annotation-xml": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "annotation-xml", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + }, + { + "tag": "div" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><annotation-xml></annotation-xml></math><div></div></body></html>", + "noQuirksBodyHtml": "<math><annotation-xml><div></div></annotation-xml></math>" + } + }, + { + "data": "<math><annotation-xml encoding=\"application/svg+xml\"><div>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,58): unexpected-html-element-in-foreign-content", + "(1,58): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math annotation-xml": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "annotation-xml", + "ns": "http://www.w3.org/1998/Math/MathML", + "attrs": [ + { + "name": "encoding", + "value": "application/svg+xml" + } + ] + } + ] + }, + { + "tag": "div" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><annotation-xml encoding=\"application/svg+xml\"></annotation-xml></math><div></div></body></html>", + "noQuirksBodyHtml": "<math><annotation-xml encoding=\"application/svg+xml\"><div></div></annotation-xml></math>" + } + }, + { + "data": "<math><annotation-xml encoding=\"application/xhtml+xml\"><div>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,60): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math annotation-xml": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "annotation-xml", + "ns": "http://www.w3.org/1998/Math/MathML", + "attrs": [ + { + "name": "encoding", + "value": "application/xhtml+xml" + } + ], + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><annotation-xml encoding=\"application/xhtml+xml\"><div></div></annotation-xml></math></body></html>", + "noQuirksBodyHtml": "<math><annotation-xml encoding=\"application/xhtml+xml\"><div></div></annotation-xml></math>" + } + }, + { + "data": "<math><annotation-xml encoding=\"aPPlication/xhtmL+xMl\"><div>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,60): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math annotation-xml": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "annotation-xml", + "ns": "http://www.w3.org/1998/Math/MathML", + "attrs": [ + { + "name": "encoding", + "value": "aPPlication/xhtmL+xMl" + } + ], + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><annotation-xml encoding=\"aPPlication/xhtmL+xMl\"><div></div></annotation-xml></math></body></html>", + "noQuirksBodyHtml": "<math><annotation-xml encoding=\"aPPlication/xhtmL+xMl\"><div></div></annotation-xml></math>" + } + }, + { + "data": "<math><annotation-xml encoding=\"text/html\"><div>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,48): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math annotation-xml": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "annotation-xml", + "ns": "http://www.w3.org/1998/Math/MathML", + "attrs": [ + { + "name": "encoding", + "value": "text/html" + } + ], + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><annotation-xml encoding=\"text/html\"><div></div></annotation-xml></math></body></html>", + "noQuirksBodyHtml": "<math><annotation-xml encoding=\"text/html\"><div></div></annotation-xml></math>" + } + }, + { + "data": "<math><annotation-xml encoding=\"Text/htmL\"><div>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,48): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math annotation-xml": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "annotation-xml", + "ns": "http://www.w3.org/1998/Math/MathML", + "attrs": [ + { + "name": "encoding", + "value": "Text/htmL" + } + ], + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><annotation-xml encoding=\"Text/htmL\"><div></div></annotation-xml></math></body></html>", + "noQuirksBodyHtml": "<math><annotation-xml encoding=\"Text/htmL\"><div></div></annotation-xml></math>" + } + }, + { + "data": "<math><annotation-xml encoding=\" text/html \"><div>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,50): unexpected-html-element-in-foreign-content", + "(1,50): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math annotation-xml": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "annotation-xml", + "ns": "http://www.w3.org/1998/Math/MathML", + "attrs": [ + { + "name": "encoding", + "value": " text/html " + } + ] + } + ] + }, + { + "tag": "div" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><annotation-xml encoding=\" text/html \"></annotation-xml></math><div></div></body></html>", + "noQuirksBodyHtml": "<math><annotation-xml encoding=\" text/html \"><div></div></annotation-xml></math>" + } + } + ], + "tests21.dat": [ + { + "data": "<svg><![CDATA[foo]]>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,20): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>foo</svg></body></html>", + "noQuirksBodyHtml": "<svg>foo</svg>" + } + }, + { + "data": "<math><![CDATA[foo]]>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,21): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "foo" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math>foo</math></body></html>", + "noQuirksBodyHtml": "<math>foo</math>" + } + }, + { + "data": "<div><![CDATA[foo]]>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,7): expected-dashes-or-doctype", + "(1,20): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "comment": "[CDATA[foo]]" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div><!--[CDATA[foo]]--></div></body></html>", + "noQuirksBodyHtml": "<div><!--[CDATA[foo]]--></div>" + } + }, + { + "data": "<svg><![CDATA[foo", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,17): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>foo</svg></body></html>", + "noQuirksBodyHtml": "<svg>foo</svg>" + } + }, + { + "data": "<svg><![CDATA[foo", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,17): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>foo</svg></body></html>", + "noQuirksBodyHtml": "<svg>foo</svg>" + } + }, + { + "data": "<svg><![CDATA[", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,14): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg></svg></body></html>", + "noQuirksBodyHtml": "<svg></svg>" + } + }, + { + "data": "<svg><![CDATA[]]>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,17): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg></svg></body></html>", + "noQuirksBodyHtml": "<svg></svg>" + } + }, + { + "data": "<svg><![CDATA[]] >]]>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,21): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "]] >", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>]] &gt;</svg></body></html>", + "noQuirksBodyHtml": "<svg>]] &gt;</svg>" + } + }, + { + "data": "<svg><![CDATA[]] >]]>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,21): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "]] >", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>]] &gt;</svg></body></html>", + "noQuirksBodyHtml": "<svg>]] &gt;</svg>" + } + }, + { + "data": "<svg><![CDATA[]]", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,16): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "]]" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>]]</svg></body></html>", + "noQuirksBodyHtml": "<svg>]]</svg>" + } + }, + { + "data": "<svg><![CDATA[]", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,15): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "]" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>]</svg></body></html>", + "noQuirksBodyHtml": "<svg>]</svg>" + } + }, + { + "data": "<svg><![CDATA[]>a", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,17): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "]>a", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>]&gt;a</svg></body></html>", + "noQuirksBodyHtml": "<svg>]&gt;a</svg>" + } + }, + { + "data": "<!DOCTYPE html><svg><![CDATA[foo]]]>", + "errors": [ + "(1,36): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo]" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg>foo]</svg></body></html>", + "noQuirksBodyHtml": "<svg>foo]</svg>" + } + }, + { + "data": "<!DOCTYPE html><svg><![CDATA[foo]]]]>", + "errors": [ + "(1,37): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo]]" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg>foo]]</svg></body></html>", + "noQuirksBodyHtml": "<svg>foo]]</svg>" + } + }, + { + "data": "<!DOCTYPE html><svg><![CDATA[foo]]]]]>", + "errors": [ + "(1,38): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "foo]]]" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg>foo]]]</svg></body></html>", + "noQuirksBodyHtml": "<svg>foo]]]</svg>" + } + }, + { + "data": "<svg><foreignObject><div><![CDATA[foo]]>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,27): expected-dashes-or-doctype", + "(1,40): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg foreignObject": true, + "div": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "div", + "children": [ + { + "comment": "[CDATA[foo]]" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg><foreignObject><div><!--[CDATA[foo]]--></div></foreignObject></svg></body></html>", + "noQuirksBodyHtml": "<svg><foreignObject><div><!--[CDATA[foo]]--></div></foreignObject></svg>" + } + }, + { + "data": "<svg><![CDATA[<svg>]]>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,22): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "<svg>", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>&lt;svg&gt;</svg></body></html>", + "noQuirksBodyHtml": "<svg>&lt;svg&gt;</svg>" + } + }, + { + "data": "<svg><![CDATA[</svg>a]]>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,24): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "</svg>a", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>&lt;/svg&gt;a</svg></body></html>", + "noQuirksBodyHtml": "<svg>&lt;/svg&gt;a</svg>" + } + }, + { + "data": "<svg><![CDATA[<svg>a", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,20): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "<svg>a", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>&lt;svg&gt;a</svg></body></html>", + "noQuirksBodyHtml": "<svg>&lt;svg&gt;a</svg>" + } + }, + { + "data": "<svg><![CDATA[</svg>a", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,21): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "</svg>a", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>&lt;/svg&gt;a</svg></body></html>", + "noQuirksBodyHtml": "<svg>&lt;/svg&gt;a</svg>" + } + }, + { + "data": "<svg><![CDATA[<svg>]]><path>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,28): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg path": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "<svg>", + "escaped": true + }, + { + "tag": "path", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>&lt;svg&gt;<path></path></svg></body></html>", + "noQuirksBodyHtml": "<svg>&lt;svg&gt;<path></path></svg>" + } + }, + { + "data": "<svg><![CDATA[<svg>]]></path>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,29): unexpected-end-tag", + "(1,29): unexpected-end-tag", + "(1,29): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "<svg>", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>&lt;svg&gt;</svg></body></html>", + "noQuirksBodyHtml": "<svg>&lt;svg&gt;</svg>" + } + }, + { + "data": "<svg><![CDATA[<svg>]]><!--path-->", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,33): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "escaped": true, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "<svg>", + "escaped": true + }, + { + "comment": "path" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>&lt;svg&gt;<!--path--></svg></body></html>", + "noQuirksBodyHtml": "<svg>&lt;svg&gt;<!--path--></svg>" + } + }, + { + "data": "<svg><![CDATA[<svg>]]>path", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,26): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "<svg>path", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>&lt;svg&gt;path</svg></body></html>", + "noQuirksBodyHtml": "<svg>&lt;svg&gt;path</svg>" + } + }, + { + "data": "<svg><![CDATA[<!--svg-->]]>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,27): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "text": "<!--svg-->", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg>&lt;!--svg--&gt;</svg></body></html>", + "noQuirksBodyHtml": "<svg>&lt;!--svg--&gt;</svg>" + } + } + ], + "tests22.dat": [ + { + "data": "<a><b><big><em><strong><div>X</a>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,33): adoption-agency-1.3", + "(1,33): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "b": true, + "big": true, + "em": true, + "strong": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "big", + "children": [ + { + "tag": "em", + "children": [ + { + "tag": "strong" + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "big", + "children": [ + { + "tag": "em", + "children": [ + { + "tag": "strong", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "a", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a><b><big><em><strong></strong></em></big></b></a><big><em><strong><div><a>X</a></div></strong></em></big></body></html>", + "noQuirksBodyHtml": "<a><b><big><em><strong></strong></em></big></b></a><big><em><strong><div><a>X</a></div></strong></em></big>" + } + }, + { + "data": "<a><b><div id=1><div id=2><div id=3><div id=4><div id=5><div id=6><div id=7><div id=8>A</a>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,91): adoption-agency-1.3", + "(1,91): adoption-agency-1.3", + "(1,91): adoption-agency-1.3", + "(1,91): adoption-agency-1.3", + "(1,91): adoption-agency-1.3", + "(1,91): adoption-agency-1.3", + "(1,91): adoption-agency-1.3", + "(1,91): adoption-agency-1.3", + "(1,91): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "b": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "b" + } + ] + }, + { + "tag": "b", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "1" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "2" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "3" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "4" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "5" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "6" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "7" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "8" + } + ], + "children": [ + { + "tag": "a", + "children": [ + { + "text": "A" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a><b></b></a><b><div id=\"1\"><a></a><div id=\"2\"><a></a><div id=\"3\"><a></a><div id=\"4\"><a></a><div id=\"5\"><a></a><div id=\"6\"><a></a><div id=\"7\"><a></a><div id=\"8\"><a>A</a></div></div></div></div></div></div></div></div></b></body></html>", + "noQuirksBodyHtml": "<a><b></b></a><b><div id=\"1\"><a></a><div id=\"2\"><a></a><div id=\"3\"><a></a><div id=\"4\"><a></a><div id=\"5\"><a></a><div id=\"6\"><a></a><div id=\"7\"><a></a><div id=\"8\"><a>A</a></div></div></div></div></div></div></div></div></b>" + } + }, + { + "data": "<a><b><div id=1><div id=2><div id=3><div id=4><div id=5><div id=6><div id=7><div id=8><div id=9>A</a>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,101): adoption-agency-1.3", + "(1,101): adoption-agency-1.3", + "(1,101): adoption-agency-1.3", + "(1,101): adoption-agency-1.3", + "(1,101): adoption-agency-1.3", + "(1,101): adoption-agency-1.3", + "(1,101): adoption-agency-1.3", + "(1,101): adoption-agency-1.3", + "(1,101): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "b": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "b" + } + ] + }, + { + "tag": "b", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "1" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "2" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "3" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "4" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "5" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "6" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "7" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "8" + } + ], + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "9" + } + ], + "children": [ + { + "text": "A" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a><b></b></a><b><div id=\"1\"><a></a><div id=\"2\"><a></a><div id=\"3\"><a></a><div id=\"4\"><a></a><div id=\"5\"><a></a><div id=\"6\"><a></a><div id=\"7\"><a></a><div id=\"8\"><a><div id=\"9\">A</div></a></div></div></div></div></div></div></div></div></b></body></html>", + "noQuirksBodyHtml": "<a><b></b></a><b><div id=\"1\"><a></a><div id=\"2\"><a></a><div id=\"3\"><a></a><div id=\"4\"><a></a><div id=\"5\"><a></a><div id=\"6\"><a></a><div id=\"7\"><a></a><div id=\"8\"><a><div id=\"9\">A</div></a></div></div></div></div></div></div></div></div></b>" + } + }, + { + "data": "<a><b><div id=1><div id=2><div id=3><div id=4><div id=5><div id=6><div id=7><div id=8><div id=9><div id=10>A</a>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,112): adoption-agency-1.3", + "(1,112): adoption-agency-1.3", + "(1,112): adoption-agency-1.3", + "(1,112): adoption-agency-1.3", + "(1,112): adoption-agency-1.3", + "(1,112): adoption-agency-1.3", + "(1,112): adoption-agency-1.3", + "(1,112): adoption-agency-1.3", + "(1,112): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "b": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "b" + } + ] + }, + { + "tag": "b", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "1" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "2" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "3" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "4" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "5" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "6" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "7" + } + ], + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "8" + } + ], + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "9" + } + ], + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "10" + } + ], + "children": [ + { + "text": "A" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a><b></b></a><b><div id=\"1\"><a></a><div id=\"2\"><a></a><div id=\"3\"><a></a><div id=\"4\"><a></a><div id=\"5\"><a></a><div id=\"6\"><a></a><div id=\"7\"><a></a><div id=\"8\"><a><div id=\"9\"><div id=\"10\">A</div></div></a></div></div></div></div></div></div></div></div></b></body></html>", + "noQuirksBodyHtml": "<a><b></b></a><b><div id=\"1\"><a></a><div id=\"2\"><a></a><div id=\"3\"><a></a><div id=\"4\"><a></a><div id=\"5\"><a></a><div id=\"6\"><a></a><div id=\"7\"><a></a><div id=\"8\"><a><div id=\"9\"><div id=\"10\">A</div></div></a></div></div></div></div></div></div></div></div></b>" + } + }, + { + "data": "<cite><b><cite><i><cite><i><cite><i><div>X</b>TEST", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,46): adoption-agency-1.3", + "(1,50): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "cite": true, + "b": true, + "i": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "cite", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "cite", + "children": [ + { + "tag": "i", + "children": [ + { + "tag": "cite", + "children": [ + { + "tag": "i", + "children": [ + { + "tag": "cite", + "children": [ + { + "tag": "i" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "i", + "children": [ + { + "tag": "i", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "X" + } + ] + }, + { + "text": "TEST" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><cite><b><cite><i><cite><i><cite><i></i></cite></i></cite></i></cite></b><i><i><div><b>X</b>TEST</div></i></i></cite></body></html>", + "noQuirksBodyHtml": "<cite><b><cite><i><cite><i><cite><i></i></cite></i></cite></i></cite></b><i><i><div><b>X</b>TEST</div></i></i></cite>" + } + } + ], + "tests23.dat": [ + { + "data": "<p><font size=4><font color=red><font size=4><font size=4><font size=4><font size=4><font size=4><font color=red><p>X", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,116): unexpected-end-tag", + "(1,117): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "font": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "color", + "value": "red" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "color", + "value": "red" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "color", + "value": "red" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "color", + "value": "red" + } + ], + "children": [ + { + "text": "X" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p><font size=\"4\"><font color=\"red\"><font size=\"4\"><font size=\"4\"><font size=\"4\"><font size=\"4\"><font size=\"4\"><font color=\"red\"></font></font></font></font></font></font></font></font></p><p><font color=\"red\"><font size=\"4\"><font size=\"4\"><font size=\"4\"><font color=\"red\">X</font></font></font></font></font></p></body></html>", + "noQuirksBodyHtml": "<p><font size=\"4\"><font color=\"red\"><font size=\"4\"><font size=\"4\"><font size=\"4\"><font size=\"4\"><font size=\"4\"><font color=\"red\"></font></font></font></font></font></font></font></font></p><p><font color=\"red\"><font size=\"4\"><font size=\"4\"><font size=\"4\"><font color=\"red\">X</font></font></font></font></font></p>" + } + }, + { + "data": "<p><font size=4><font size=4><font size=4><font size=4><p>X", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,58): unexpected-end-tag", + "(1,59): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "font": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "text": "X" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p><font size=\"4\"><font size=\"4\"><font size=\"4\"><font size=\"4\"></font></font></font></font></p><p><font size=\"4\"><font size=\"4\"><font size=\"4\">X</font></font></font></p></body></html>", + "noQuirksBodyHtml": "<p><font size=\"4\"><font size=\"4\"><font size=\"4\"><font size=\"4\"></font></font></font></font></p><p><font size=\"4\"><font size=\"4\"><font size=\"4\">X</font></font></font></p>" + } + }, + { + "data": "<p><font size=4><font size=4><font size=4><font size=\"5\"><font size=4><p>X", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,73): unexpected-end-tag", + "(1,74): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "font": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "5" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "5" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "text": "X" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p><font size=\"4\"><font size=\"4\"><font size=\"4\"><font size=\"5\"><font size=\"4\"></font></font></font></font></font></p><p><font size=\"4\"><font size=\"4\"><font size=\"5\"><font size=\"4\">X</font></font></font></font></p></body></html>", + "noQuirksBodyHtml": "<p><font size=\"4\"><font size=\"4\"><font size=\"4\"><font size=\"5\"><font size=\"4\"></font></font></font></font></font></p><p><font size=\"4\"><font size=\"4\"><font size=\"5\"><font size=\"4\">X</font></font></font></font></p>" + } + }, + { + "data": "<p><font size=4 id=a><font size=4 id=b><font size=4><font size=4><p>X", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,68): unexpected-end-tag", + "(1,69): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "font": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "id", + "value": "a" + }, + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "id", + "value": "b" + }, + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "id", + "value": "a" + }, + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "id", + "value": "b" + }, + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "4" + } + ], + "children": [ + { + "text": "X" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p><font size=\"4\" id=\"a\"><font size=\"4\" id=\"b\"><font size=\"4\"><font size=\"4\"></font></font></font></font></p><p><font size=\"4\" id=\"a\"><font size=\"4\" id=\"b\"><font size=\"4\"><font size=\"4\">X</font></font></font></font></p></body></html>", + "noQuirksBodyHtml": "<p><font size=\"4\" id=\"a\"><font size=\"4\" id=\"b\"><font size=\"4\"><font size=\"4\"></font></font></font></font></p><p><font size=\"4\" id=\"a\"><font size=\"4\" id=\"b\"><font size=\"4\"><font size=\"4\">X</font></font></font></font></p>" + } + }, + { + "data": "<p><b id=a><b id=a><b id=a><b><object><b id=a><b id=a>X</object><p>Y", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,64): end-tag-too-early", + "(1,67): unexpected-end-tag", + "(1,68): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "b": true, + "object": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "b", + "attrs": [ + { + "name": "id", + "value": "a" + } + ], + "children": [ + { + "tag": "b", + "attrs": [ + { + "name": "id", + "value": "a" + } + ], + "children": [ + { + "tag": "b", + "attrs": [ + { + "name": "id", + "value": "a" + } + ], + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "object", + "children": [ + { + "tag": "b", + "attrs": [ + { + "name": "id", + "value": "a" + } + ], + "children": [ + { + "tag": "b", + "attrs": [ + { + "name": "id", + "value": "a" + } + ], + "children": [ + { + "text": "X" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "tag": "b", + "attrs": [ + { + "name": "id", + "value": "a" + } + ], + "children": [ + { + "tag": "b", + "attrs": [ + { + "name": "id", + "value": "a" + } + ], + "children": [ + { + "tag": "b", + "attrs": [ + { + "name": "id", + "value": "a" + } + ], + "children": [ + { + "tag": "b", + "children": [ + { + "text": "Y" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p><b id=\"a\"><b id=\"a\"><b id=\"a\"><b><object><b id=\"a\"><b id=\"a\">X</b></b></object></b></b></b></b></p><p><b id=\"a\"><b id=\"a\"><b id=\"a\"><b>Y</b></b></b></b></p></body></html>", + "noQuirksBodyHtml": "<p><b id=\"a\"><b id=\"a\"><b id=\"a\"><b><object><b id=\"a\"><b id=\"a\">X</b></b></object></b></b></b></b></p><p><b id=\"a\"><b id=\"a\"><b id=\"a\"><b>Y</b></b></b></b></p>" + } + } + ], + "tests24.dat": [ + { + "data": "<!DOCTYPE html>&NotEqualTilde;", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "≂̸" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>≂̸</body></html>", + "noQuirksBodyHtml": "≂̸" + } + }, + { + "data": "<!DOCTYPE html>&NotEqualTilde;A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "≂̸A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>≂̸A</body></html>", + "noQuirksBodyHtml": "≂̸A" + } + }, + { + "data": "<!DOCTYPE html>&ThickSpace;", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "  " + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>  </body></html>", + "noQuirksBodyHtml": "  " + } + }, + { + "data": "<!DOCTYPE html>&ThickSpace;A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "  A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>  A</body></html>", + "noQuirksBodyHtml": "  A" + } + }, + { + "data": "<!DOCTYPE html>&NotSubset;", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "⊂⃒" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>⊂⃒</body></html>", + "noQuirksBodyHtml": "⊂⃒" + } + }, + { + "data": "<!DOCTYPE html>&NotSubset;A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "⊂⃒A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>⊂⃒A</body></html>", + "noQuirksBodyHtml": "⊂⃒A" + } + }, + { + "data": "<!DOCTYPE html>&Gopf;", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "𝔾" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>𝔾</body></html>", + "noQuirksBodyHtml": "𝔾" + } + }, + { + "data": "<!DOCTYPE html>&Gopf;A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "𝔾A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>𝔾A</body></html>", + "noQuirksBodyHtml": "𝔾A" + } + } + ], + "tests25.dat": [ + { + "data": "<!DOCTYPE html><body><foo>A", + "errors": [ + "(1,27): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "foo": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "foo", + "children": [ + { + "text": "A" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><foo>A</foo></body></html>", + "noQuirksBodyHtml": "<foo>A</foo>" + } + }, + { + "data": "<!DOCTYPE html><body><area>A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "area": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "area" + }, + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><area>A</body></html>", + "noQuirksBodyHtml": "<area>A" + } + }, + { + "data": "<!DOCTYPE html><body><base>A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "base": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "base" + }, + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><base>A</body></html>", + "noQuirksBodyHtml": "<base>A" + } + }, + { + "data": "<!DOCTYPE html><body><basefont>A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "basefont": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "basefont" + }, + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><basefont>A</body></html>", + "noQuirksBodyHtml": "<basefont>A" + } + }, + { + "data": "<!DOCTYPE html><body><bgsound>A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "bgsound": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "bgsound" + }, + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><bgsound>A</body></html>", + "noQuirksBodyHtml": "<bgsound>A" + } + }, + { + "data": "<!DOCTYPE html><body><br>A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "br": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "br" + }, + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><br>A</body></html>", + "noQuirksBodyHtml": "<br>A" + } + }, + { + "data": "<!DOCTYPE html><body><col>A", + "errors": [ + "(1,26): unexpected-start-tag-ignored" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>A</body></html>", + "noQuirksBodyHtml": "A" + } + }, + { + "data": "<!DOCTYPE html><body><command>A", + "errors": [ + "eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "command": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "command", + "children": [ + { + "text": "A" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><command>A</command></body></html>", + "noQuirksBodyHtml": "<command>A</command>" + } + }, + { + "data": "<!DOCTYPE html><body><menuitem>A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "menuitem": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "menuitem" + }, + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><menuitem>A</body></html>", + "noQuirksBodyHtml": "<menuitem>A" + } + }, + { + "data": "<!DOCTYPE html><body><embed>A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "embed": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "embed" + }, + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><embed>A</body></html>", + "noQuirksBodyHtml": "<embed>A" + } + }, + { + "data": "<!DOCTYPE html><body><frame>A", + "errors": [ + "(1,28): unexpected-start-tag-ignored" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>A</body></html>", + "noQuirksBodyHtml": "A" + } + }, + { + "data": "<!DOCTYPE html><body><hr>A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "hr": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "hr" + }, + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><hr>A</body></html>", + "noQuirksBodyHtml": "<hr>A" + } + }, + { + "data": "<!DOCTYPE html><body><img>A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "img": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "img" + }, + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><img>A</body></html>", + "noQuirksBodyHtml": "<img>A" + } + }, + { + "data": "<!DOCTYPE html><body><input>A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "input": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "input" + }, + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><input>A</body></html>", + "noQuirksBodyHtml": "<input>A" + } + }, + { + "data": "<!DOCTYPE html><body><keygen>A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "keygen": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "keygen" + }, + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><keygen>A</body></html>", + "noQuirksBodyHtml": "<keygen>A" + } + }, + { + "data": "<!DOCTYPE html><body><link>A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "link": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "link" + }, + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><link>A</body></html>", + "noQuirksBodyHtml": "<link>A" + } + }, + { + "data": "<!DOCTYPE html><body><meta>A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "meta": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "meta" + }, + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><meta>A</body></html>", + "noQuirksBodyHtml": "<meta>A" + } + }, + { + "data": "<!DOCTYPE html><body><param>A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "param": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "param" + }, + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><param>A</body></html>", + "noQuirksBodyHtml": "<param>A" + } + }, + { + "data": "<!DOCTYPE html><body><source>A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "source": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "source" + }, + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><source>A</body></html>", + "noQuirksBodyHtml": "<source>A" + } + }, + { + "data": "<!DOCTYPE html><body><track>A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "track": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "track" + }, + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><track>A</body></html>", + "noQuirksBodyHtml": "<track>A" + } + }, + { + "data": "<!DOCTYPE html><body><wbr>A", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "wbr": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "wbr" + }, + { + "text": "A" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><wbr>A</body></html>", + "noQuirksBodyHtml": "<wbr>A" + } + } + ], + "tests26.dat": [ + { + "data": "<!DOCTYPE html><body><a href='#1'><nobr>1<nobr></a><br><a href='#2'><nobr>2<nobr></a><br><a href='#3'><nobr>3<nobr></a>", + "errors": [ + "(1,47): unexpected-start-tag-implies-end-tag", + "(1,51): adoption-agency-1.3", + "(1,74): unexpected-start-tag-implies-end-tag", + "(1,74): adoption-agency-1.3", + "(1,81): unexpected-start-tag-implies-end-tag", + "(1,85): adoption-agency-1.3", + "(1,108): unexpected-start-tag-implies-end-tag", + "(1,108): adoption-agency-1.3", + "(1,115): unexpected-start-tag-implies-end-tag", + "(1,119): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "nobr": true, + "br": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "attrs": [ + { + "name": "href", + "value": "#1" + } + ], + "children": [ + { + "tag": "nobr", + "children": [ + { + "text": "1" + } + ] + }, + { + "tag": "nobr" + } + ] + }, + { + "tag": "nobr", + "children": [ + { + "tag": "br" + }, + { + "tag": "a", + "attrs": [ + { + "name": "href", + "value": "#2" + } + ] + } + ] + }, + { + "tag": "a", + "attrs": [ + { + "name": "href", + "value": "#2" + } + ], + "children": [ + { + "tag": "nobr", + "children": [ + { + "text": "2" + } + ] + }, + { + "tag": "nobr" + } + ] + }, + { + "tag": "nobr", + "children": [ + { + "tag": "br" + }, + { + "tag": "a", + "attrs": [ + { + "name": "href", + "value": "#3" + } + ] + } + ] + }, + { + "tag": "a", + "attrs": [ + { + "name": "href", + "value": "#3" + } + ], + "children": [ + { + "tag": "nobr", + "children": [ + { + "text": "3" + } + ] + }, + { + "tag": "nobr" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><a href=\"#1\"><nobr>1</nobr><nobr></nobr></a><nobr><br><a href=\"#2\"></a></nobr><a href=\"#2\"><nobr>2</nobr><nobr></nobr></a><nobr><br><a href=\"#3\"></a></nobr><a href=\"#3\"><nobr>3</nobr><nobr></nobr></a></body></html>", + "noQuirksBodyHtml": "<a href=\"#1\"><nobr>1</nobr><nobr></nobr></a><nobr><br><a href=\"#2\"></a></nobr><a href=\"#2\"><nobr>2</nobr><nobr></nobr></a><nobr><br><a href=\"#3\"></a></nobr><a href=\"#3\"><nobr>3</nobr><nobr></nobr></a>" + } + }, + { + "data": "<!DOCTYPE html><body><b><nobr>1<nobr></b><i><nobr>2<nobr></i>3", + "errors": [ + "(1,37): unexpected-start-tag-implies-end-tag", + "(1,41): adoption-agency-1.3", + "(1,50): unexpected-start-tag-implies-end-tag", + "(1,50): adoption-agency-1.3", + "(1,57): unexpected-start-tag-implies-end-tag", + "(1,61): adoption-agency-1.3", + "(1,62): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "nobr": true, + "i": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "nobr", + "children": [ + { + "text": "1" + } + ] + }, + { + "tag": "nobr" + } + ] + }, + { + "tag": "nobr", + "children": [ + { + "tag": "i" + } + ] + }, + { + "tag": "i", + "children": [ + { + "tag": "nobr", + "children": [ + { + "text": "2" + } + ] + }, + { + "tag": "nobr" + } + ] + }, + { + "tag": "nobr", + "children": [ + { + "text": "3" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><b><nobr>1</nobr><nobr></nobr></b><nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr></body></html>", + "noQuirksBodyHtml": "<b><nobr>1</nobr><nobr></nobr></b><nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr>" + } + }, + { + "data": "<!DOCTYPE html><body><b><nobr>1<table><nobr></b><i><nobr>2<nobr></i>3", + "errors": [ + "(1,44): foster-parenting-start-tag", + "(1,48): foster-parenting-end-tag", + "(1,48): adoption-agency-1.3", + "(1,51): foster-parenting-start-tag", + "(1,57): foster-parenting-start-tag", + "(1,57): nobr-already-in-scope", + "(1,57): adoption-agency-1.2", + "(1,58): foster-parenting-character", + "(1,64): foster-parenting-start-tag", + "(1,64): nobr-already-in-scope", + "(1,68): foster-parenting-end-tag", + "(1,68): adoption-agency-1.2", + "(1,69): foster-parenting-character", + "(1,69): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "nobr": true, + "i": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "nobr", + "children": [ + { + "text": "1" + }, + { + "tag": "nobr", + "children": [ + { + "tag": "i" + } + ] + }, + { + "tag": "i", + "children": [ + { + "tag": "nobr", + "children": [ + { + "text": "2" + } + ] + }, + { + "tag": "nobr" + } + ] + }, + { + "tag": "nobr", + "children": [ + { + "text": "3" + } + ] + }, + { + "tag": "table" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><b><nobr>1<nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr><table></table></nobr></b></body></html>", + "noQuirksBodyHtml": "<b><nobr>1<nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr><table></table></nobr></b>" + } + }, + { + "data": "<!DOCTYPE html><body><b><nobr>1<table><tr><td><nobr></b><i><nobr>2<nobr></i>3", + "errors": [ + "(1,56): unexpected-end-tag", + "(1,65): unexpected-start-tag-implies-end-tag", + "(1,65): adoption-agency-1.3", + "(1,72): unexpected-start-tag-implies-end-tag", + "(1,76): adoption-agency-1.3", + "(1,77): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "nobr": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "i": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "nobr", + "children": [ + { + "text": "1" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "nobr", + "children": [ + { + "tag": "i" + } + ] + }, + { + "tag": "i", + "children": [ + { + "tag": "nobr", + "children": [ + { + "text": "2" + } + ] + }, + { + "tag": "nobr" + } + ] + }, + { + "tag": "nobr", + "children": [ + { + "text": "3" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><b><nobr>1<table><tbody><tr><td><nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr></td></tr></tbody></table></nobr></b></body></html>", + "noQuirksBodyHtml": "<b><nobr>1<table><tbody><tr><td><nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr></td></tr></tbody></table></nobr></b>" + } + }, + { + "data": "<!DOCTYPE html><body><b><nobr>1<div><nobr></b><i><nobr>2<nobr></i>3", + "errors": [ + "(1,42): unexpected-start-tag-implies-end-tag", + "(1,42): adoption-agency-1.3", + "(1,46): adoption-agency-1.3", + "(1,46): adoption-agency-1.3", + "(1,55): unexpected-start-tag-implies-end-tag", + "(1,55): adoption-agency-1.3", + "(1,62): unexpected-start-tag-implies-end-tag", + "(1,66): adoption-agency-1.3", + "(1,67): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "nobr": true, + "div": true, + "i": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "nobr", + "children": [ + { + "text": "1" + } + ] + } + ] + }, + { + "tag": "div", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "nobr" + }, + { + "tag": "nobr" + } + ] + }, + { + "tag": "nobr", + "children": [ + { + "tag": "i" + } + ] + }, + { + "tag": "i", + "children": [ + { + "tag": "nobr", + "children": [ + { + "text": "2" + } + ] + }, + { + "tag": "nobr" + } + ] + }, + { + "tag": "nobr", + "children": [ + { + "text": "3" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><b><nobr>1</nobr></b><div><b><nobr></nobr><nobr></nobr></b><nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr></div></body></html>", + "noQuirksBodyHtml": "<b><nobr>1</nobr></b><div><b><nobr></nobr><nobr></nobr></b><nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr></div>" + } + }, + { + "data": "<!DOCTYPE html><body><b><nobr>1<nobr></b><div><i><nobr>2<nobr></i>3", + "errors": [ + "(1,37): unexpected-start-tag-implies-end-tag", + "(1,41): adoption-agency-1.3", + "(1,55): unexpected-start-tag-implies-end-tag", + "(1,55): adoption-agency-1.3", + "(1,62): unexpected-start-tag-implies-end-tag", + "(1,66): adoption-agency-1.3", + "(1,67): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "nobr": true, + "div": true, + "i": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "nobr", + "children": [ + { + "text": "1" + } + ] + }, + { + "tag": "nobr" + } + ] + }, + { + "tag": "div", + "children": [ + { + "tag": "nobr", + "children": [ + { + "tag": "i" + } + ] + }, + { + "tag": "i", + "children": [ + { + "tag": "nobr", + "children": [ + { + "text": "2" + } + ] + }, + { + "tag": "nobr" + } + ] + }, + { + "tag": "nobr", + "children": [ + { + "text": "3" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><b><nobr>1</nobr><nobr></nobr></b><div><nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr></div></body></html>", + "noQuirksBodyHtml": "<b><nobr>1</nobr><nobr></nobr></b><div><nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr></div>" + } + }, + { + "data": "<!DOCTYPE html><body><b><nobr>1<nobr><ins></b><i><nobr>", + "errors": [ + "(1,37): unexpected-start-tag-implies-end-tag", + "(1,46): adoption-agency-1.3", + "(1,55): unexpected-start-tag-implies-end-tag", + "(1,55): adoption-agency-1.3", + "(1,55): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "nobr": true, + "ins": true, + "i": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "nobr", + "children": [ + { + "text": "1" + } + ] + }, + { + "tag": "nobr", + "children": [ + { + "tag": "ins" + } + ] + } + ] + }, + { + "tag": "nobr", + "children": [ + { + "tag": "i" + } + ] + }, + { + "tag": "i", + "children": [ + { + "tag": "nobr" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><b><nobr>1</nobr><nobr><ins></ins></nobr></b><nobr><i></i></nobr><i><nobr></nobr></i></body></html>", + "noQuirksBodyHtml": "<b><nobr>1</nobr><nobr><ins></ins></nobr></b><nobr><i></i></nobr><i><nobr></nobr></i>" + } + }, + { + "data": "<!DOCTYPE html><body><b><nobr>1<ins><nobr></b><i>2", + "errors": [ + "(1,42): unexpected-start-tag-implies-end-tag", + "(1,42): adoption-agency-1.3", + "(1,46): adoption-agency-1.3", + "(1,50): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "nobr": true, + "ins": true, + "i": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "nobr", + "children": [ + { + "text": "1" + }, + { + "tag": "ins" + } + ] + }, + { + "tag": "nobr" + } + ] + }, + { + "tag": "nobr", + "children": [ + { + "tag": "i", + "children": [ + { + "text": "2" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><b><nobr>1<ins></ins></nobr><nobr></nobr></b><nobr><i>2</i></nobr></body></html>", + "noQuirksBodyHtml": "<b><nobr>1<ins></ins></nobr><nobr></nobr></b><nobr><i>2</i></nobr>" + } + }, + { + "data": "<!DOCTYPE html><body><b>1<nobr></b><i><nobr>2</i>", + "errors": [ + "(1,35): adoption-agency-1.3", + "(1,44): unexpected-start-tag-implies-end-tag", + "(1,44): adoption-agency-1.3", + "(1,49): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "nobr": true, + "i": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "1" + }, + { + "tag": "nobr" + } + ] + }, + { + "tag": "nobr", + "children": [ + { + "tag": "i" + } + ] + }, + { + "tag": "i", + "children": [ + { + "tag": "nobr", + "children": [ + { + "text": "2" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><b>1<nobr></nobr></b><nobr><i></i></nobr><i><nobr>2</nobr></i></body></html>", + "noQuirksBodyHtml": "<b>1<nobr></nobr></b><nobr><i></i></nobr><i><nobr>2</nobr></i>" + } + }, + { + "data": "<p><code x</code></p>\n", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,11): invalid-character-in-attribute-name", + "(1,12): unexpected-character-after-solidus-in-tag", + "(1,21): unexpected-end-tag", + "(2,0): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "code": true + }, + "attrWithFunnyChar": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "code", + "attrs": [ + { + "name": "code", + "value": "" + }, + { + "name": "x<", + "value": "" + } + ] + } + ] + }, + { + "tag": "code", + "attrs": [ + { + "name": "code", + "value": "" + }, + { + "name": "x<", + "value": "" + } + ], + "children": [ + { + "text": "\n" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p><code x<=\"\" code=\"\"></code></p><code x<=\"\" code=\"\">\n</code></body></html>", + "noQuirksBodyHtml": "<p><code x<=\"\" code=\"\"></code></p><code x<=\"\" code=\"\">\n</code>" + } + }, + { + "data": "<!DOCTYPE html><svg><foreignObject><p><i></p>a", + "errors": [ + "(1,45): unexpected-end-tag", + "(1,46): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg foreignObject": true, + "p": true, + "i": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "i" + } + ] + }, + { + "tag": "i", + "children": [ + { + "text": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><svg><foreignObject><p><i></i></p><i>a</i></foreignObject></svg></body></html>", + "noQuirksBodyHtml": "<svg><foreignObject><p><i></i></p><i>a</i></foreignObject></svg>" + } + }, + { + "data": "<!DOCTYPE html><table><tr><td><svg><foreignObject><p><i></p>a", + "errors": [ + "(1,60): unexpected-end-tag", + "(1,61): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "svg svg": true, + "svg foreignObject": true, + "p": true, + "i": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "i" + } + ] + }, + { + "tag": "i", + "children": [ + { + "text": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><svg><foreignObject><p><i></i></p><i>a</i></foreignObject></svg></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><svg><foreignObject><p><i></i></p><i>a</i></foreignObject></svg></td></tr></tbody></table>" + } + }, + { + "data": "<!DOCTYPE html><math><mtext><p><i></p>a", + "errors": [ + "(1,38): unexpected-end-tag", + "(1,39): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mtext": true, + "p": true, + "i": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mtext", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "i" + } + ] + }, + { + "tag": "i", + "children": [ + { + "text": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math><mtext><p><i></i></p><i>a</i></mtext></math></body></html>", + "noQuirksBodyHtml": "<math><mtext><p><i></i></p><i>a</i></mtext></math>" + } + }, + { + "data": "<!DOCTYPE html><table><tr><td><math><mtext><p><i></p>a", + "errors": [ + "(1,53): unexpected-end-tag", + "(1,54): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "math math": true, + "math mtext": true, + "p": true, + "i": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mtext", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "i" + } + ] + }, + { + "tag": "i", + "children": [ + { + "text": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><math><mtext><p><i></i></p><i>a</i></mtext></math></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><math><mtext><p><i></i></p><i>a</i></mtext></math></td></tr></tbody></table>" + } + }, + { + "data": "<!DOCTYPE html><body><div><!/div>a", + "errors": [ + "(1,28): expected-dashes-or-doctype", + "(1,34): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + }, + "doctype": true, + "comment": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "comment": "/div" + }, + { + "text": "a" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><div><!--/div-->a</div></body></html>", + "noQuirksBodyHtml": "<div><!--/div-->a</div>" + } + }, + { + "data": "<button><p><button>", + "errors": [ + "Line 1 Col 8 Unexpected start tag (button). Expected DOCTYPE.", + "Line 1 Col 19 Unexpected start tag (button) implies end tag (button).", + "Line 1 Col 19 Expected closing tag. Unexpected end of file." + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "button": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "button", + "children": [ + { + "tag": "p" + } + ] + }, + { + "tag": "button" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><button><p></p></button><button></button></body></html>", + "noQuirksBodyHtml": "<button><p></p></button><button></button>" + } + } + ], + "tests3.dat": [ + { + "data": "<head></head><style></style>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,20): unexpected-start-tag-out-of-my-head" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style" + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><style></style></head><body></body></html>", + "noQuirksBodyHtml": "<style></style>" + } + }, + { + "data": "<head></head><script></script>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,21): unexpected-start-tag-out-of-my-head" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script" + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><script></script></head><body></body></html>", + "noQuirksBodyHtml": "<script></script>" + } + }, + { + "data": "<head></head><!-- --><style></style><!-- --><script></script>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,28): unexpected-start-tag-out-of-my-head", + "(1,52): unexpected-start-tag-out-of-my-head" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "script": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style" + }, + { + "tag": "script" + } + ] + }, + { + "comment": " " + }, + { + "comment": " " + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><style></style><script></script></head><!-- --><!-- --><body></body></html>", + "noQuirksBodyHtml": "<!-- --><style></style><!-- --><script></script>" + } + }, + { + "data": "<head></head><!-- -->x<style></style><!-- --><script></script>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "style": true, + "script": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "comment": " " + }, + { + "tag": "body", + "children": [ + { + "text": "x" + }, + { + "tag": "style" + }, + { + "comment": " " + }, + { + "tag": "script" + } + ] + } + ] + } + ], + "html": "<html><head></head><!-- --><body>x<style></style><!-- --><script></script></body></html>", + "noQuirksBodyHtml": "<!-- -->x<style></style><!-- --><script></script>" + } + }, + { + "data": "<!DOCTYPE html><html><head></head><body><pre>\n</pre></body></html>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "pre": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "pre" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><pre></pre></body></html>", + "noQuirksBodyHtml": "<pre></pre>" + } + }, + { + "data": "<!DOCTYPE html><html><head></head><body><pre>\nfoo</pre></body></html>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "pre": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "pre", + "children": [ + { + "text": "foo" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><pre>foo</pre></body></html>", + "noQuirksBodyHtml": "<pre>foo</pre>" + } + }, + { + "data": "<!DOCTYPE html><html><head></head><body><pre>\n\nfoo</pre></body></html>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "pre": true + }, + "doctype": true, + "extraNL": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "pre", + "children": [ + { + "text": "\nfoo", + "extraNL": true + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><pre>\n\nfoo</pre></body></html>", + "noQuirksBodyHtml": "<pre>\n\nfoo</pre>" + } + }, + { + "data": "<!DOCTYPE html><html><head></head><body><pre>\nfoo\n</pre></body></html>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "pre": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "pre", + "children": [ + { + "text": "foo\n" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><pre>foo\n</pre></body></html>", + "noQuirksBodyHtml": "<pre>foo\n</pre>" + } + }, + { + "data": "<!DOCTYPE html><html><head></head><body><pre>x</pre><span>\n</span></body></html>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "pre": true, + "span": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "pre", + "children": [ + { + "text": "x" + } + ] + }, + { + "tag": "span", + "children": [ + { + "text": "\n" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><pre>x</pre><span>\n</span></body></html>", + "noQuirksBodyHtml": "<pre>x</pre><span>\n</span>" + } + }, + { + "data": "<!DOCTYPE html><html><head></head><body><pre>x\ny</pre></body></html>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "pre": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "pre", + "children": [ + { + "text": "x\ny" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><pre>x\ny</pre></body></html>", + "noQuirksBodyHtml": "<pre>x\ny</pre>" + } + }, + { + "data": "<!DOCTYPE html><html><head></head><body><pre>x<div>\ny</pre></body></html>", + "errors": [ + "(2,7): end-tag-too-early" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "pre": true, + "div": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "pre", + "children": [ + { + "text": "x" + }, + { + "tag": "div", + "children": [ + { + "text": "\ny" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><pre>x<div>\ny</div></pre></body></html>", + "noQuirksBodyHtml": "<pre>x<div>\ny</div></pre>" + } + }, + { + "data": "<!DOCTYPE html><pre>&#x0a;&#x0a;A</pre>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "pre": true + }, + "doctype": true, + "extraNL": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "pre", + "children": [ + { + "text": "\nA", + "extraNL": true + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><pre>\n\nA</pre></body></html>", + "noQuirksBodyHtml": "<pre>\n\nA</pre>" + } + }, + { + "data": "<!DOCTYPE html><HTML><META><HEAD></HEAD></HTML>", + "errors": [ + "(1,33): two-heads-are-not-better-than-one" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "meta": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "meta" + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><meta></head><body></body></html>", + "noQuirksBodyHtml": "<meta>" + } + }, + { + "data": "<!DOCTYPE html><HTML><HEAD><head></HEAD></HTML>", + "errors": [ + "(1,33): two-heads-are-not-better-than-one" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<textarea>foo<span>bar</span><i>baz", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(1,35): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "textarea": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "textarea", + "children": [ + { + "text": "foo<span>bar</span><i>baz", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><textarea>foo&lt;span&gt;bar&lt;/span&gt;&lt;i&gt;baz</textarea></body></html>", + "noQuirksBodyHtml": "<textarea>foo&lt;span&gt;bar&lt;/span&gt;&lt;i&gt;baz</textarea>" + } + }, + { + "data": "<title>foo<span>bar</em><i>baz", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,30): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "foo<span>bar</em><i>baz", + "escaped": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><title>foo&lt;span&gt;bar&lt;/em&gt;&lt;i&gt;baz</title></head><body></body></html>", + "noQuirksBodyHtml": "<title>foo&lt;span&gt;bar&lt;/em&gt;&lt;i&gt;baz</title>" + } + }, + { + "data": "<!DOCTYPE html><textarea>\n</textarea>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "textarea": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "textarea" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><textarea></textarea></body></html>", + "noQuirksBodyHtml": "<textarea></textarea>" + } + }, + { + "data": "<!DOCTYPE html><textarea>\nfoo</textarea>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "textarea": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "textarea", + "children": [ + { + "text": "foo" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><textarea>foo</textarea></body></html>", + "noQuirksBodyHtml": "<textarea>foo</textarea>" + } + }, + { + "data": "<!DOCTYPE html><textarea>\n\nfoo</textarea>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "textarea": true + }, + "doctype": true, + "extraNL": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "textarea", + "children": [ + { + "text": "\nfoo", + "extraNL": true + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><textarea>\n\nfoo</textarea></body></html>", + "noQuirksBodyHtml": "<textarea>\n\nfoo</textarea>" + } + }, + { + "data": "<!DOCTYPE html><html><head></head><body><ul><li><div><p><li></ul></body></html>", + "errors": [ + "(1,60): end-tag-too-early" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ul": true, + "li": true, + "div": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ul", + "children": [ + { + "tag": "li", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "p" + } + ] + } + ] + }, + { + "tag": "li" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><ul><li><div><p></p></div></li><li></li></ul></body></html>", + "noQuirksBodyHtml": "<ul><li><div><p></p></div></li><li></li></ul>" + } + }, + { + "data": "<!doctype html><nobr><nobr><nobr>", + "errors": [ + "(1,27): unexpected-start-tag-implies-end-tag", + "(1,33): unexpected-start-tag-implies-end-tag", + "(1,33): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "nobr": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "nobr" + }, + { + "tag": "nobr" + }, + { + "tag": "nobr" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><nobr></nobr><nobr></nobr><nobr></nobr></body></html>", + "noQuirksBodyHtml": "<nobr></nobr><nobr></nobr><nobr></nobr>" + } + }, + { + "data": "<!doctype html><nobr><nobr></nobr><nobr>", + "errors": [ + "(1,27): unexpected-start-tag-implies-end-tag", + "(1,40): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "nobr": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "nobr" + }, + { + "tag": "nobr" + }, + { + "tag": "nobr" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><nobr></nobr><nobr></nobr><nobr></nobr></body></html>", + "noQuirksBodyHtml": "<nobr></nobr><nobr></nobr><nobr></nobr>" + } + }, + { + "data": "<!doctype html><html><body><p><table></table></body></html>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p" + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p></p><table></table></body></html>", + "noQuirksBodyHtml": "<p></p><table></table>" + } + }, + { + "data": "<p><table></table>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "table": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "table" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p><table></table></p></body></html>", + "noQuirksBodyHtml": "<p></p><table></table>" + } + } + ], + "tests4.dat": [ + { + "data": "direct div content", + "errors": [], + "fragment": { + "name": "div" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "direct div content" + } + ], + "html": "direct div content", + "noQuirksBodyHtml": "direct div content" + } + }, + { + "data": "direct textarea content", + "errors": [], + "fragment": { + "name": "textarea" + }, + "document": { + "props": { + "tags": {} + }, + "tree": [ + { + "text": "direct textarea content" + } + ], + "html": "direct textarea content", + "noQuirksBodyHtml": "direct textarea content" + } + }, + { + "data": "textarea content with <em>pseudo</em> <foo>markup", + "errors": [], + "fragment": { + "name": "textarea" + }, + "document": { + "props": { + "tags": {}, + "escaped": true + }, + "tree": [ + { + "text": "textarea content with <em>pseudo</em> <foo>markup", + "escaped": true + } + ], + "html": "textarea content with &lt;em&gt;pseudo&lt;/em&gt; &lt;foo&gt;markup", + "noQuirksBodyHtml": "textarea content with <em>pseudo</em> <foo>markup</foo>" + } + }, + { + "data": "this is &#x0043;DATA inside a <style> element", + "errors": [], + "fragment": { + "name": "style" + }, + "document": { + "props": { + "tags": {}, + "no_escape": true + }, + "tree": [ + { + "text": "this is &#x0043;DATA inside a <style> element", + "no_escape": true + } + ], + "html": "this is &#x0043;DATA inside a <style> element", + "noQuirksBodyHtml": "this is CDATA inside a <style> element</style>" + } + }, + { + "data": "</plaintext>", + "errors": [], + "fragment": { + "name": "plaintext" + }, + "document": { + "props": { + "tags": {}, + "no_escape": true + }, + "tree": [ + { + "text": "</plaintext>", + "no_escape": true + } + ], + "html": "</plaintext>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "setting html's innerHTML", + "errors": [], + "fragment": { + "name": "html" + }, + "document": { + "props": { + "tags": { + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "setting html's innerHTML" + } + ] + } + ], + "html": "<head></head><body>setting html's innerHTML</body>", + "noQuirksBodyHtml": "setting html's innerHTML" + } + }, + { + "data": "<title>setting head's innerHTML</title>", + "errors": [], + "fragment": { + "name": "head" + }, + "document": { + "props": { + "tags": { + "title": true + } + }, + "tree": [ + { + "tag": "title", + "children": [ + { + "text": "setting head's innerHTML" + } + ] + } + ], + "html": "<title>setting head's innerHTML</title>", + "noQuirksBodyHtml": "<title>setting head's innerHTML</title>" + } + } + ], + "tests5.dat": [ + { + "data": "<style> <!-- </style>x", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": " <!-- ", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "x" + } + ] + } + ] + } + ], + "html": "<html><head><style> <!-- </style></head><body>x</body></html>", + "noQuirksBodyHtml": "<style> <!-- </style>x" + } + }, + { + "data": "<style> <!-- </style> --> </style>x", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,34): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": " <!-- ", + "no_escape": true + } + ] + }, + { + "text": " " + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "--> x", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head><style> <!-- </style> </head><body>--&gt; x</body></html>", + "noQuirksBodyHtml": "<style> <!-- </style> --&gt; x" + } + }, + { + "data": "<style> <!--> </style>x", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": " <!--> ", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "x" + } + ] + } + ] + } + ], + "html": "<html><head><style> <!--> </style></head><body>x</body></html>", + "noQuirksBodyHtml": "<style> <!--> </style>x" + } + }, + { + "data": "<style> <!---> </style>x", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": " <!---> ", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "x" + } + ] + } + ] + } + ], + "html": "<html><head><style> <!---> </style></head><body>x</body></html>", + "noQuirksBodyHtml": "<style> <!---> </style>x" + } + }, + { + "data": "<iframe> <!---> </iframe>x", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "iframe": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "iframe", + "children": [ + { + "text": " <!---> ", + "no_escape": true + } + ] + }, + { + "text": "x" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><iframe> <!---> </iframe>x</body></html>", + "noQuirksBodyHtml": "<iframe> <!---> </iframe>x" + } + }, + { + "data": "<iframe> <!--- </iframe>->x</iframe> --> </iframe>x", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,36): unexpected-end-tag", + "(1,50): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "iframe": true + }, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "iframe", + "children": [ + { + "text": " <!--- ", + "no_escape": true + } + ] + }, + { + "text": "->x --> x", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body><iframe> <!--- </iframe>-&gt;x --&gt; x</body></html>", + "noQuirksBodyHtml": "<iframe> <!--- </iframe>-&gt;x --&gt; x" + } + }, + { + "data": "<script> <!-- </script> --> </script>x", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,37): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "script": true, + "body": true + }, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "script", + "children": [ + { + "text": " <!-- ", + "no_escape": true + } + ] + }, + { + "text": " " + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "--> x", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head><script> <!-- </script> </head><body>--&gt; x</body></html>", + "noQuirksBodyHtml": "<script> <!-- </script> --&gt; x" + } + }, + { + "data": "<title> <!-- </title> --> </title>x", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,34): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "title", + "children": [ + { + "text": " <!-- ", + "escaped": true + } + ] + }, + { + "text": " " + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "--> x", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head><title> &lt;!-- </title> </head><body>--&gt; x</body></html>", + "noQuirksBodyHtml": "<title> &lt;!-- </title> --&gt; x" + } + }, + { + "data": "<textarea> <!--- </textarea>->x</textarea> --> </textarea>x", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(1,42): unexpected-end-tag", + "(1,58): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "textarea": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "textarea", + "children": [ + { + "text": " <!--- ", + "escaped": true + } + ] + }, + { + "text": "->x --> x", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body><textarea> &lt;!--- </textarea>-&gt;x --&gt; x</body></html>", + "noQuirksBodyHtml": "<textarea> &lt;!--- </textarea>-&gt;x --&gt; x" + } + }, + { + "data": "<style> <!</-- </style>x", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style", + "children": [ + { + "text": " <!</-- ", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "x" + } + ] + } + ] + } + ], + "html": "<html><head><style> <!</-- </style></head><body>x</body></html>", + "noQuirksBodyHtml": "<style> <!</-- </style>x" + } + }, + { + "data": "<p><xmp></xmp>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "xmp": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p" + }, + { + "tag": "xmp" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p></p><xmp></xmp></body></html>", + "noQuirksBodyHtml": "<p></p><xmp></xmp>" + } + }, + { + "data": "<xmp> <!-- > --> </xmp>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "xmp": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "xmp", + "children": [ + { + "text": " <!-- > --> ", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><xmp> <!-- > --> </xmp></body></html>", + "noQuirksBodyHtml": "<xmp> <!-- > --> </xmp>" + } + }, + { + "data": "<title>&amp;</title>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "&", + "escaped": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><title>&amp;</title></head><body></body></html>", + "noQuirksBodyHtml": "<title>&amp;</title>" + } + }, + { + "data": "<title><!--&amp;--></title>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "<!--&-->", + "escaped": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><title>&lt;!--&amp;--&gt;</title></head><body></body></html>", + "noQuirksBodyHtml": "<title>&lt;!--&amp;--&gt;</title>" + } + }, + { + "data": "<title><!--</title>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "<!--", + "escaped": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><title>&lt;!--</title></head><body></body></html>", + "noQuirksBodyHtml": "<title>&lt;!--</title>" + } + }, + { + "data": "<noscript><!--</noscript>--></noscript>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(1,39): unexpected-end-tag" + ], + "script": "on", + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "noscript": true, + "body": true + }, + "no_escape": true, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "noscript", + "children": [ + { + "text": "<!--", + "no_escape": true + } + ] + } + ] + }, + { + "tag": "body", + "children": [ + { + "text": "-->", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head><noscript><!--</noscript></head><body>--&gt;</body></html>", + "noQuirksBodyHtml": "<noscript>&lt;!--</noscript>--&gt;" + } + }, + { + "data": "<noscript><!--</noscript>--></noscript>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag" + ], + "script": "off", + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "noscript": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "noscript", + "children": [ + { + "comment": "</noscript>" + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head><noscript><!--</noscript>--></noscript></head><body></body></html>", + "noQuirksBodyHtml": "<noscript>&lt;!--</noscript>--&gt;" + } + } + ], + "tests6.dat": [ + { + "data": "<!doctype html></head> <head>", + "errors": [ + "(1,29): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "text": " " + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head> <body></body></html>", + "noQuirksBodyHtml": " " + } + }, + { + "data": "<!doctype html><form><div></form><div>", + "errors": [ + "(1,33): end-tag-too-early-ignored", + "(1,38): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "form": true, + "div": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "form", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><form><div><div></div></div></form></body></html>", + "noQuirksBodyHtml": "<form><div><div></div></div></form>" + } + }, + { + "data": "<!doctype html><title>&amp;</title>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "body": true + }, + "doctype": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "&", + "escaped": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><title>&amp;</title></head><body></body></html>", + "noQuirksBodyHtml": "<title>&amp;</title>" + } + }, + { + "data": "<!doctype html><title><!--&amp;--></title>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "body": true + }, + "doctype": true, + "escaped": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "<!--&-->", + "escaped": true + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><title>&lt;!--&amp;--&gt;</title></head><body></body></html>", + "noQuirksBodyHtml": "<title>&lt;!--&amp;--&gt;</title>" + } + }, + { + "data": "<!doctype>", + "errors": [ + "(1,9): need-space-after-doctype", + "(1,10): expected-doctype-name-but-got-right-bracket", + "(1,10): unknown-doctype" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE ><html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<!---x", + "errors": [ + "(1,6): eof-in-comment", + "(1,6): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "comment": "-x" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!---x--><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!---x-->" + } + }, + { + "data": "<body>\n<div>", + "errors": [ + "(1,6): unexpected-start-tag", + "(2,5): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "div" + }, + "document": { + "props": { + "tags": { + "div": true + } + }, + "tree": [ + { + "text": "\n" + }, + { + "tag": "div" + } + ], + "html": "\n<div></div>", + "noQuirksBodyHtml": "\n<div></div>" + } + }, + { + "data": "<frameset></frameset>\nfoo", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(2,1): unexpected-char-after-frameset", + "(2,2): unexpected-char-after-frameset", + "(2,3): unexpected-char-after-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + }, + { + "text": "\n" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset>\n</html>", + "noQuirksBodyHtml": "\nfoo" + } + }, + { + "data": "<frameset></frameset>\n<noframes>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(2,10): expected-named-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true, + "noframes": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + }, + { + "text": "\n" + }, + { + "tag": "noframes" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset>\n<noframes></noframes></html>", + "noQuirksBodyHtml": "\n<noframes></noframes>" + } + }, + { + "data": "<frameset></frameset>\n<div>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(2,5): unexpected-start-tag-after-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + }, + { + "text": "\n" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset>\n</html>", + "noQuirksBodyHtml": "\n<div></div>" + } + }, + { + "data": "<frameset></frameset>\n</html>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + }, + { + "text": "\n" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset>\n</html>", + "noQuirksBodyHtml": "\n" + } + }, + { + "data": "<frameset></frameset>\n</div>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(2,6): unexpected-end-tag-after-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + }, + { + "text": "\n" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset>\n</html>", + "noQuirksBodyHtml": "\n" + } + }, + { + "data": "<form><form>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,12): unexpected-start-tag", + "(1,12): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "form": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "form" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><form></form></body></html>", + "noQuirksBodyHtml": "<form></form>" + } + }, + { + "data": "<button><button>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,16): unexpected-start-tag-implies-end-tag", + "(1,16): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "button": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "button" + }, + { + "tag": "button" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><button></button><button></button></body></html>", + "noQuirksBodyHtml": "<button></button><button></button>" + } + }, + { + "data": "<table><tr><td></th>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,20): unexpected-end-tag", + "(1,20): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><td></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td></td></tr></tbody></table>" + } + }, + { + "data": "<table><caption><td>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,20): unexpected-cell-in-table-body", + "(1,20): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "caption": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "caption" + }, + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><caption></caption><tbody><tr><td></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><caption></caption><tbody><tr><td></td></tr></tbody></table>" + } + }, + { + "data": "<table><caption><div>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,21): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "caption": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "caption", + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><caption><div></div></caption></table></body></html>", + "noQuirksBodyHtml": "<table><caption><div></div></caption></table>" + } + }, + { + "data": "</caption><div>", + "errors": [ + "(1,10): XXX-undefined-error", + "(1,15): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "caption" + }, + "document": { + "props": { + "tags": { + "div": true + } + }, + "tree": [ + { + "tag": "div" + } + ], + "html": "<div></div>", + "noQuirksBodyHtml": "<div></div>" + } + }, + { + "data": "<table><caption><div></caption>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,31): expected-one-end-tag-but-got-another", + "(1,31): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "caption": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "caption", + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><caption><div></div></caption></table></body></html>", + "noQuirksBodyHtml": "<table><caption><div></div></caption></table>" + } + }, + { + "data": "<table><caption></table>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "caption": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "caption" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><caption></caption></table></body></html>", + "noQuirksBodyHtml": "<table><caption></caption></table>" + } + }, + { + "data": "</table><div>", + "errors": [ + "(1,8): unexpected-end-tag", + "(1,13): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "caption" + }, + "document": { + "props": { + "tags": { + "div": true + } + }, + "tree": [ + { + "tag": "div" + } + ], + "html": "<div></div>", + "noQuirksBodyHtml": "<div></div>" + } + }, + { + "data": "<table><caption></body></col></colgroup></html></tbody></td></tfoot></th></thead></tr>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,23): unexpected-end-tag", + "(1,29): unexpected-end-tag", + "(1,40): unexpected-end-tag", + "(1,47): unexpected-end-tag", + "(1,55): unexpected-end-tag", + "(1,60): unexpected-end-tag", + "(1,68): unexpected-end-tag", + "(1,73): unexpected-end-tag", + "(1,81): unexpected-end-tag", + "(1,86): unexpected-end-tag", + "(1,86): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "caption": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "caption" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><caption></caption></table></body></html>", + "noQuirksBodyHtml": "<table><caption></caption></table>" + } + }, + { + "data": "<table><caption><div></div>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,27): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "caption": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "caption", + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><caption><div></div></caption></table></body></html>", + "noQuirksBodyHtml": "<table><caption><div></div></caption></table>" + } + }, + { + "data": "<table><tr><td></body></caption></col></colgroup></html>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,22): unexpected-end-tag", + "(1,32): unexpected-end-tag", + "(1,38): unexpected-end-tag", + "(1,49): unexpected-end-tag", + "(1,56): unexpected-end-tag", + "(1,56): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><td></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td></td></tr></tbody></table>" + } + }, + { + "data": "</table></tbody></tfoot></thead></tr><div>", + "errors": [ + "(1,8): unexpected-end-tag", + "(1,16): unexpected-end-tag", + "(1,24): unexpected-end-tag", + "(1,32): unexpected-end-tag", + "(1,37): unexpected-end-tag", + "(1,42): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "td" + }, + "document": { + "props": { + "tags": { + "div": true + } + }, + "tree": [ + { + "tag": "div" + } + ], + "html": "<div></div>", + "noQuirksBodyHtml": "<div></div>" + } + }, + { + "data": "<table><colgroup>foo", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,18): foster-parenting-character-in-table", + "(1,19): foster-parenting-character-in-table", + "(1,20): foster-parenting-character-in-table", + "(1,20): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "colgroup": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "foo" + }, + { + "tag": "table", + "children": [ + { + "tag": "colgroup" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body>foo<table><colgroup></colgroup></table></body></html>", + "noQuirksBodyHtml": "foo<table><colgroup></colgroup></table>" + } + }, + { + "data": "foo<col>", + "errors": [ + "(1,1): unexpected-character-in-colgroup", + "(1,2): unexpected-character-in-colgroup", + "(1,3): unexpected-character-in-colgroup" + ], + "fragment": { + "name": "colgroup" + }, + "document": { + "props": { + "tags": { + "col": true + } + }, + "tree": [ + { + "tag": "col" + } + ], + "html": "<col>", + "noQuirksBodyHtml": "foo" + } + }, + { + "data": "<table><colgroup></col>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,23): no-end-tag", + "(1,23): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "colgroup": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "colgroup" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><colgroup></colgroup></table></body></html>", + "noQuirksBodyHtml": "<table><colgroup></colgroup></table>" + } + }, + { + "data": "<frameset><div>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(1,15): unexpected-start-tag-in-frameset", + "(1,15): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<div></div>" + } + }, + { + "data": "</frameset><frame>", + "errors": [ + "(1,11): unexpected-frameset-in-frameset-innerhtml" + ], + "fragment": { + "name": "frameset" + }, + "document": { + "props": { + "tags": { + "frame": true + } + }, + "tree": [ + { + "tag": "frame" + } + ], + "html": "<frame>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<frameset></div>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag", + "(1,16): unexpected-end-tag-in-frameset", + "(1,16): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "</body><div>", + "errors": [ + "(1,7): unexpected-close-tag", + "(1,12): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "body" + }, + "document": { + "props": { + "tags": { + "div": true + } + }, + "tree": [ + { + "tag": "div" + } + ], + "html": "<div></div>", + "noQuirksBodyHtml": "<div></div>" + } + }, + { + "data": "<table><tr><div>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,16): unexpected-start-tag-implies-table-voodoo", + "(1,16): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "table": true, + "tbody": true, + "tr": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div></div><table><tbody><tr></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<div></div><table><tbody><tr></tr></tbody></table>" + } + }, + { + "data": "</tr><td>", + "errors": [ + "(1,5): unexpected-end-tag" + ], + "fragment": { + "name": "tr" + }, + "document": { + "props": { + "tags": { + "td": true + } + }, + "tree": [ + { + "tag": "td" + } + ], + "html": "<td></td>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "</tbody></tfoot></thead><td>", + "errors": [ + "(1,8): unexpected-end-tag", + "(1,16): unexpected-end-tag", + "(1,24): unexpected-end-tag" + ], + "fragment": { + "name": "tr" + }, + "document": { + "props": { + "tags": { + "td": true + } + }, + "tree": [ + { + "tag": "td" + } + ], + "html": "<td></td>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<table><tr><div><td>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,16): foster-parenting-start-tag", + "(1,20): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div></div><table><tbody><tr><td></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<div></div><table><tbody><tr><td></td></tr></tbody></table>" + } + }, + { + "data": "<caption><col><colgroup><tbody><tfoot><thead><tr>", + "errors": [ + "(1,9): unexpected-start-tag", + "(1,14): unexpected-start-tag", + "(1,24): unexpected-start-tag", + "(1,31): unexpected-start-tag", + "(1,38): unexpected-start-tag", + "(1,45): unexpected-start-tag" + ], + "fragment": { + "name": "tbody" + }, + "document": { + "props": { + "tags": { + "tr": true + } + }, + "tree": [ + { + "tag": "tr" + } + ], + "html": "<tr></tr>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<table><tbody></thead>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,22): unexpected-end-tag-in-table-body", + "(1,22): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody></tbody></table>" + } + }, + { + "data": "</table><tr>", + "errors": [ + "(1,8): unexpected-end-tag" + ], + "fragment": { + "name": "tbody" + }, + "document": { + "props": { + "tags": { + "tr": true + } + }, + "tree": [ + { + "tag": "tr" + } + ], + "html": "<tr></tr>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<table><tbody></body></caption></col></colgroup></html></td></th></tr>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,21): unexpected-end-tag-in-table-body", + "(1,31): unexpected-end-tag-in-table-body", + "(1,37): unexpected-end-tag-in-table-body", + "(1,48): unexpected-end-tag-in-table-body", + "(1,55): unexpected-end-tag-in-table-body", + "(1,60): unexpected-end-tag-in-table-body", + "(1,65): unexpected-end-tag-in-table-body", + "(1,70): unexpected-end-tag-in-table-body", + "(1,70): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody></tbody></table>" + } + }, + { + "data": "<table><tbody></div>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,20): unexpected-end-tag-implies-table-voodoo", + "(1,20): end-tag-too-early", + "(1,20): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody></tbody></table>" + } + }, + { + "data": "<table><table>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,14): unexpected-start-tag-implies-end-tag", + "(1,14): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table" + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table></table><table></table></body></html>", + "noQuirksBodyHtml": "<table></table><table></table>" + } + }, + { + "data": "<table></body></caption></col></colgroup></html></tbody></td></tfoot></th></thead></tr>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,14): unexpected-end-tag", + "(1,24): unexpected-end-tag", + "(1,30): unexpected-end-tag", + "(1,41): unexpected-end-tag", + "(1,48): unexpected-end-tag", + "(1,56): unexpected-end-tag", + "(1,61): unexpected-end-tag", + "(1,69): unexpected-end-tag", + "(1,74): unexpected-end-tag", + "(1,82): unexpected-end-tag", + "(1,87): unexpected-end-tag", + "(1,87): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table></table></body></html>", + "noQuirksBodyHtml": "<table></table>" + } + }, + { + "data": "</table><tr>", + "errors": [ + "(1,8): unexpected-end-tag" + ], + "fragment": { + "name": "table" + }, + "document": { + "props": { + "tags": { + "tbody": true, + "tr": true + } + }, + "tree": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ], + "html": "<tbody><tr></tr></tbody>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<body></body></html>", + "errors": [ + "(1,20): unexpected-end-tag-after-body-innerhtml" + ], + "fragment": { + "name": "html" + }, + "document": { + "props": { + "tags": { + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ], + "html": "<head></head><body></body>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<html><frameset></frameset></html> ", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + }, + { + "text": " " + } + ] + } + ], + "html": "<html><head></head><frameset></frameset> </html>", + "noQuirksBodyHtml": " " + } + }, + { + "data": "<!DOCTYPE html PUBLIC \"-//W3C//DTD HTML 4.01//EN\"><html></html>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html \"-//W3C//DTD HTML 4.01//EN\" \"\"" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<param><frameset></frameset>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,17): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<param>" + } + }, + { + "data": "<source><frameset></frameset>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,18): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<source>" + } + }, + { + "data": "<track><frameset></frameset>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,17): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<track>" + } + }, + { + "data": "</html><frameset></frameset>", + "errors": [ + "(1,7): expected-doctype-but-got-end-tag", + "(1,17): expected-eof-but-got-start-tag", + "(1,17): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "</body><frameset></frameset>", + "errors": [ + "(1,7): expected-doctype-but-got-end-tag", + "(1,17): unexpected-start-tag-after-body", + "(1,17): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "" + } + } + ], + "tests7.dat": [ + { + "data": "<!doctype html><body><title>X</title>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "title": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><title>X</title></body></html>", + "noQuirksBodyHtml": "<title>X</title>" + } + }, + { + "data": "<!doctype html><table><title>X</title></table>", + "errors": [ + "(1,29): unexpected-start-tag-implies-table-voodoo" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "title": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "X" + } + ] + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><title>X</title><table></table></body></html>", + "noQuirksBodyHtml": "<title>X</title><table></table>" + } + }, + { + "data": "<!doctype html><head></head><title>X</title>", + "errors": [ + "(1,35): unexpected-start-tag-out-of-my-head" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "X" + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><title>X</title></head><body></body></html>", + "noQuirksBodyHtml": "<title>X</title>" + } + }, + { + "data": "<!doctype html></head><title>X</title>", + "errors": [ + "(1,29): unexpected-start-tag-out-of-my-head" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "title": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "title", + "children": [ + { + "text": "X" + } + ] + } + ] + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head><title>X</title></head><body></body></html>", + "noQuirksBodyHtml": "<title>X</title>" + } + }, + { + "data": "<!doctype html><table><meta></table>", + "errors": [ + "(1,28): unexpected-start-tag-implies-table-voodoo" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "meta": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "meta" + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><meta><table></table></body></html>", + "noQuirksBodyHtml": "<meta><table></table>" + } + }, + { + "data": "<!doctype html><table>X<tr><td><table> <meta></table></table>", + "errors": [ + "unexpected text in table", + "(1,45): unexpected-start-tag-implies-table-voodoo" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "meta": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "X" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "meta" + }, + { + "tag": "table", + "children": [ + { + "text": " " + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>X<table><tbody><tr><td><meta><table> </table></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "X<table><tbody><tr><td><meta><table> </table></td></tr></tbody></table>" + } + }, + { + "data": "<!doctype html><html> <head>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body></body></html>", + "noQuirksBodyHtml": " " + } + }, + { + "data": "<!doctype html> <head>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body></body></html>", + "noQuirksBodyHtml": " " + } + }, + { + "data": "<!doctype html><table><style> <tr>x </style> </table>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "style": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "style", + "children": [ + { + "text": " <tr>x ", + "no_escape": true + } + ] + }, + { + "text": " " + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><style> <tr>x </style> </table></body></html>", + "noQuirksBodyHtml": "<table><style> <tr>x </style> </table>" + } + }, + { + "data": "<!doctype html><table><TBODY><script> <tr>x </script> </table>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "script": true + }, + "doctype": true, + "no_escape": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "script", + "children": [ + { + "text": " <tr>x ", + "no_escape": true + } + ] + }, + { + "text": " " + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><script> <tr>x </script> </tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><script> <tr>x </script> </tbody></table>" + } + }, + { + "data": "<!doctype html><p><applet><p>X</p></applet>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "applet": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "applet", + "children": [ + { + "tag": "p", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><applet><p>X</p></applet></p></body></html>", + "noQuirksBodyHtml": "<p><applet><p>X</p></applet></p>" + } + }, + { + "data": "<!doctype html><p><object type=\"application/x-non-existant-plugin\"><p>X</p></object>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "object": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "object", + "attrs": [ + { + "name": "type", + "value": "application/x-non-existant-plugin" + } + ], + "children": [ + { + "tag": "p", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><p><object type=\"application/x-non-existant-plugin\"><p>X</p></object></p></body></html>", + "noQuirksBodyHtml": "<p><object type=\"application/x-non-existant-plugin\"><p>X</p></object></p>" + } + }, + { + "data": "<!doctype html><listing>\nX</listing>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "listing": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "listing", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><listing>X</listing></body></html>", + "noQuirksBodyHtml": "<listing>X</listing>" + } + }, + { + "data": "<!doctype html><select><input>X", + "errors": [ + "(1,30): unexpected-input-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "input": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + }, + { + "tag": "input" + }, + { + "text": "X" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select></select><input>X</body></html>", + "noQuirksBodyHtml": "<select></select><input>X" + } + }, + { + "data": "<!doctype html><select><select>X", + "errors": [ + "(1,31): unexpected-select-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + }, + { + "text": "X" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select></select>X</body></html>", + "noQuirksBodyHtml": "<select></select>X" + } + }, + { + "data": "<!doctype html><table><input type=hidDEN></table>", + "errors": [ + "(1,41): unexpected-hidden-input-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "input": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "input", + "attrs": [ + { + "name": "type", + "value": "hidDEN" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><input type=\"hidDEN\"></table></body></html>", + "noQuirksBodyHtml": "<table><input type=\"hidDEN\"></table>" + } + }, + { + "data": "<!doctype html><table>X<input type=hidDEN></table>", + "errors": [ + "(1,23): foster-parenting-character", + "(1,42): unexpected-hidden-input-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "input": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "X" + }, + { + "tag": "table", + "children": [ + { + "tag": "input", + "attrs": [ + { + "name": "type", + "value": "hidDEN" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body>X<table><input type=\"hidDEN\"></table></body></html>", + "noQuirksBodyHtml": "X<table><input type=\"hidDEN\"></table>" + } + }, + { + "data": "<!doctype html><table> <input type=hidDEN></table>", + "errors": [ + "(1,43): unexpected-hidden-input-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "input": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "text": " " + }, + { + "tag": "input", + "attrs": [ + { + "name": "type", + "value": "hidDEN" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table> <input type=\"hidDEN\"></table></body></html>", + "noQuirksBodyHtml": "<table> <input type=\"hidDEN\"></table>" + } + }, + { + "data": "<!doctype html><table> <input type='hidDEN'></table>", + "errors": [ + "(1,45): unexpected-hidden-input-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "input": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "text": " " + }, + { + "tag": "input", + "attrs": [ + { + "name": "type", + "value": "hidDEN" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table> <input type=\"hidDEN\"></table></body></html>", + "noQuirksBodyHtml": "<table> <input type=\"hidDEN\"></table>" + } + }, + { + "data": "<!doctype html><table><input type=\" hidden\"><input type=hidDEN></table>", + "errors": [ + "(1,44): unexpected-start-tag-implies-table-voodoo", + "(1,63): unexpected-hidden-input-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "input": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "input", + "attrs": [ + { + "name": "type", + "value": " hidden" + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "input", + "attrs": [ + { + "name": "type", + "value": "hidDEN" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><input type=\" hidden\"><table><input type=\"hidDEN\"></table></body></html>", + "noQuirksBodyHtml": "<input type=\" hidden\"><table><input type=\"hidDEN\"></table>" + } + }, + { + "data": "<!doctype html><table><select>X<tr>", + "errors": [ + "(1,30): unexpected-start-tag-implies-table-voodoo", + "(1,35): unexpected-table-element-start-tag-in-select-in-table", + "(1,35): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "table": true, + "tbody": true, + "tr": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "text": "X" + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select>X</select><table><tbody><tr></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<select>X</select><table><tbody><tr></tr></tbody></table>" + } + }, + { + "data": "<!doctype html><select>X</select>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "text": "X" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select>X</select></body></html>", + "noQuirksBodyHtml": "<select>X</select>" + } + }, + { + "data": "<!DOCTYPE hTmL><html></html>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<!DOCTYPE HTML><html></html>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<body>X</body></body>", + "errors": [ + "(1,21): unexpected-end-tag-after-body" + ], + "fragment": { + "name": "html" + }, + "document": { + "props": { + "tags": { + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "X" + } + ] + } + ], + "html": "<head></head><body>X</body>", + "noQuirksBodyHtml": "X" + } + }, + { + "data": "<div><p>a</x> b", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,13): unexpected-end-tag", + "(1,15): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "p", + "children": [ + { + "text": "a b" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div><p>a b</p></div></body></html>", + "noQuirksBodyHtml": "<div><p>a b</p></div>" + } + }, + { + "data": "<table><tr><td><code></code> </table>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "code": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "code" + }, + { + "text": " " + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><td><code></code> </td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><code></code> </td></tr></tbody></table>" + } + }, + { + "data": "<table><b><tr><td>aaa</td></tr>bbb</table>ccc", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,10): foster-parenting-start-tag", + "(1,32): foster-parenting-character", + "(1,33): foster-parenting-character", + "(1,34): foster-parenting-character", + "(1,45): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b" + }, + { + "tag": "b", + "children": [ + { + "text": "bbb" + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "text": "aaa" + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "b", + "children": [ + { + "text": "ccc" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b></b><b>bbb</b><table><tbody><tr><td>aaa</td></tr></tbody></table><b>ccc</b></body></html>", + "noQuirksBodyHtml": "<b></b><b>bbb</b><table><tbody><tr><td>aaa</td></tr></tbody></table><b>ccc</b>" + } + }, + { + "data": "A<table><tr> B</tr> B</table>", + "errors": [ + "(1,1): expected-doctype-but-got-chars", + "(1,13): foster-parenting-character", + "(1,14): foster-parenting-character", + "(1,20): foster-parenting-character", + "(1,21): foster-parenting-character" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "A B B" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body>A B B<table><tbody><tr></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "A B B<table><tbody><tr></tr></tbody></table>" + } + }, + { + "data": "A<table><tr> B</tr> </em>C</table>", + "errors": [ + "(1,1): expected-doctype-but-got-chars", + "(1,13): foster-parenting-character", + "(1,14): foster-parenting-character", + "(1,20): foster-parenting-character", + "(1,25): unexpected-end-tag", + "(1,26): foster-parenting-character" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "A BC" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + }, + { + "text": " " + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body>A BC<table><tbody><tr></tr> </tbody></table></body></html>", + "noQuirksBodyHtml": "A BC<table><tbody><tr></tr> </tbody></table>" + } + }, + { + "data": "<select><keygen>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,16): unexpected-input-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "keygen": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + }, + { + "tag": "keygen" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><select></select><keygen></body></html>", + "noQuirksBodyHtml": "<select></select><keygen>" + } + } + ], + "tests8.dat": [ + { + "data": "<div>\n<div></div>\n</span>x", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(3,7): unexpected-end-tag", + "(3,8): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "\n" + }, + { + "tag": "div" + }, + { + "text": "\nx" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div>\n<div></div>\nx</div></body></html>", + "noQuirksBodyHtml": "<div>\n<div></div>\nx</div>" + } + }, + { + "data": "<div>x<div></div>\n</span>x", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(2,7): unexpected-end-tag", + "(2,8): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "x" + }, + { + "tag": "div" + }, + { + "text": "\nx" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div>x<div></div>\nx</div></body></html>", + "noQuirksBodyHtml": "<div>x<div></div>\nx</div>" + } + }, + { + "data": "<div>x<div></div>x</span>x", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,25): unexpected-end-tag", + "(1,26): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "x" + }, + { + "tag": "div" + }, + { + "text": "xx" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div>x<div></div>xx</div></body></html>", + "noQuirksBodyHtml": "<div>x<div></div>xx</div>" + } + }, + { + "data": "<div>x<div></div>y</span>z", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,25): unexpected-end-tag", + "(1,26): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "x" + }, + { + "tag": "div" + }, + { + "text": "yz" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div>x<div></div>yz</div></body></html>", + "noQuirksBodyHtml": "<div>x<div></div>yz</div>" + } + }, + { + "data": "<table><div>x<div></div>x</span>x", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,12): foster-parenting-start-tag", + "(1,13): foster-parenting-character", + "(1,18): foster-parenting-start-tag", + "(1,24): foster-parenting-end-tag", + "(1,25): foster-parenting-start-tag", + "(1,32): foster-parenting-end-tag", + "(1,32): unexpected-end-tag", + "(1,33): foster-parenting-character", + "(1,33): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "table": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "x" + }, + { + "tag": "div" + }, + { + "text": "xx" + } + ] + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div>x<div></div>xx</div><table></table></body></html>", + "noQuirksBodyHtml": "<div>x<div></div>xx</div><table></table>" + } + }, + { + "data": "x<table>x", + "errors": [ + "(1,1): expected-doctype-but-got-chars", + "(1,9): foster-parenting-character", + "(1,9): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "xx" + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>xx<table></table></body></html>", + "noQuirksBodyHtml": "xx<table></table>" + } + }, + { + "data": "x<table><table>x", + "errors": [ + "(1,1): expected-doctype-but-got-chars", + "(1,15): unexpected-start-tag-implies-end-tag", + "(1,16): foster-parenting-character", + "(1,16): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "x" + }, + { + "tag": "table" + }, + { + "text": "x" + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>x<table></table>x<table></table></body></html>", + "noQuirksBodyHtml": "x<table></table>x<table></table>" + } + }, + { + "data": "<b>a<div></div><div></b>y", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,24): adoption-agency-1.3", + "(1,25): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "a" + }, + { + "tag": "div" + } + ] + }, + { + "tag": "div", + "children": [ + { + "tag": "b" + }, + { + "text": "y" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b>a<div></div></b><div><b></b>y</div></body></html>", + "noQuirksBodyHtml": "<b>a<div></div></b><div><b></b>y</div>" + } + }, + { + "data": "<a><div><p></a>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,15): adoption-agency-1.3", + "(1,15): adoption-agency-1.3", + "(1,15): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "div": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "children": [ + { + "tag": "a" + }, + { + "tag": "p", + "children": [ + { + "tag": "a" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a></a><div><a></a><p><a></a></p></div></body></html>", + "noQuirksBodyHtml": "<a></a><div><a></a><p><a></a></p></div>" + } + } + ], + "tests9.dat": [ + { + "data": "<!DOCTYPE html><math></math>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math></math></body></html>", + "noQuirksBodyHtml": "<math></math>" + } + }, + { + "data": "<!DOCTYPE html><body><math></math>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math></math></body></html>", + "noQuirksBodyHtml": "<math></math>" + } + }, + { + "data": "<!DOCTYPE html><math><mi>", + "errors": [ + "(1,25) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mi": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math><mi></mi></math></body></html>", + "noQuirksBodyHtml": "<math><mi></mi></math>" + } + }, + { + "data": "<!DOCTYPE html><math><annotation-xml><svg><u>", + "errors": [ + "(1,45) unexpected-html-element-in-foreign-content", + "(1,45) expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math annotation-xml": true, + "svg svg": true, + "u": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "annotation-xml", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + }, + { + "tag": "u" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math><annotation-xml><svg></svg></annotation-xml></math><u></u></body></html>", + "noQuirksBodyHtml": "<math><annotation-xml><svg><u></u></svg></annotation-xml></math>" + } + }, + { + "data": "<!DOCTYPE html><body><select><math></math></select>", + "errors": [ + "(1,35) unexpected-start-tag-in-select", + "(1,42) unexpected-end-tag-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>", + "noQuirksBodyHtml": "<select></select>" + } + }, + { + "data": "<!DOCTYPE html><body><select><option><math></math></option></select>", + "errors": [ + "(1,43) unexpected-start-tag-in-select", + "(1,50) unexpected-end-tag-in-select" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "option": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "option" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select><option></option></select></body></html>", + "noQuirksBodyHtml": "<select><option></option></select>" + } + }, + { + "data": "<!DOCTYPE html><body><table><math></math></table>", + "errors": [ + "(1,34) unexpected-start-tag-implies-table-voodoo" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML" + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math></math><table></table></body></html>", + "noQuirksBodyHtml": "<math></math><table></table>" + } + }, + { + "data": "<!DOCTYPE html><body><table><math><mi>foo</mi></math></table>", + "errors": [ + "(1,34) foster-parenting-start-token", + "(1,39) foster-parenting-character", + "(1,40) foster-parenting-character", + "(1,41) foster-parenting-character" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mi": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "foo" + } + ] + } + ] + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math><mi>foo</mi></math><table></table></body></html>", + "noQuirksBodyHtml": "<math><mi>foo</mi></math><table></table>" + } + }, + { + "data": "<!DOCTYPE html><body><table><math><mi>foo</mi><mi>bar</mi></math></table>", + "errors": [ + "(1,34) foster-parenting-start-tag", + "(1,39) foster-parenting-character", + "(1,40) foster-parenting-character", + "(1,41) foster-parenting-character", + "(1,51) foster-parenting-character", + "(1,52) foster-parenting-character", + "(1,53) foster-parenting-character" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mi": true, + "table": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "bar" + } + ] + } + ] + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math><mi>foo</mi><mi>bar</mi></math><table></table></body></html>", + "noQuirksBodyHtml": "<math><mi>foo</mi><mi>bar</mi></math><table></table>" + } + }, + { + "data": "<!DOCTYPE html><body><table><tbody><math><mi>foo</mi><mi>bar</mi></math></tbody></table>", + "errors": [ + "(1,41) foster-parenting-start-tag", + "(1,46) foster-parenting-character", + "(1,47) foster-parenting-character", + "(1,48) foster-parenting-character", + "(1,58) foster-parenting-character", + "(1,59) foster-parenting-character", + "(1,60) foster-parenting-character" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mi": true, + "table": true, + "tbody": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "bar" + } + ] + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math><mi>foo</mi><mi>bar</mi></math><table><tbody></tbody></table></body></html>", + "noQuirksBodyHtml": "<math><mi>foo</mi><mi>bar</mi></math><table><tbody></tbody></table>" + } + }, + { + "data": "<!DOCTYPE html><body><table><tbody><tr><math><mi>foo</mi><mi>bar</mi></math></tr></tbody></table>", + "errors": [ + "(1,45) foster-parenting-start-tag", + "(1,50) foster-parenting-character", + "(1,51) foster-parenting-character", + "(1,52) foster-parenting-character", + "(1,62) foster-parenting-character", + "(1,63) foster-parenting-character", + "(1,64) foster-parenting-character" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mi": true, + "table": true, + "tbody": true, + "tr": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "bar" + } + ] + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math><mi>foo</mi><mi>bar</mi></math><table><tbody><tr></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<math><mi>foo</mi><mi>bar</mi></math><table><tbody><tr></tr></tbody></table>" + } + }, + { + "data": "<!DOCTYPE html><body><table><tbody><tr><td><math><mi>foo</mi><mi>bar</mi></math></td></tr></tbody></table>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "math math": true, + "math mi": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "bar" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><math><mi>foo</mi><mi>bar</mi></math></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><math><mi>foo</mi><mi>bar</mi></math></td></tr></tbody></table>" + } + }, + { + "data": "<!DOCTYPE html><body><table><tbody><tr><td><math><mi>foo</mi><mi>bar</mi></math><p>baz</td></tr></tbody></table>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "math math": true, + "math mi": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "bar" + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "baz" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><math><mi>foo</mi><mi>bar</mi></math><p>baz</p></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><math><mi>foo</mi><mi>bar</mi></math><p>baz</p></td></tr></tbody></table>" + } + }, + { + "data": "<!DOCTYPE html><body><table><caption><math><mi>foo</mi><mi>bar</mi></math><p>baz</caption></table>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "caption": true, + "math math": true, + "math mi": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "caption", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "bar" + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "baz" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><caption><math><mi>foo</mi><mi>bar</mi></math><p>baz</p></caption></table></body></html>", + "noQuirksBodyHtml": "<table><caption><math><mi>foo</mi><mi>bar</mi></math><p>baz</p></caption></table>" + } + }, + { + "data": "<!DOCTYPE html><body><table><caption><math><mi>foo</mi><mi>bar</mi><p>baz</table><p>quux", + "errors": [ + "(1,70) unexpected-html-element-in-foreign-content" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "caption": true, + "math math": true, + "math mi": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "caption", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "bar" + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "baz" + } + ] + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "quux" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><caption><math><mi>foo</mi><mi>bar</mi></math><p>baz</p></caption></table><p>quux</p></body></html>", + "noQuirksBodyHtml": "<table><caption><math><mi>foo</mi><mi>bar</mi><p>baz</p></math></caption></table><p>quux</p>" + } + }, + { + "data": "<!DOCTYPE html><body><table><caption><math><mi>foo</mi><mi>bar</mi>baz</table><p>quux", + "errors": [ + "(1,78) unexpected-end-tag", + "(1,78) expected-one-end-tag-but-got-another" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "caption": true, + "math math": true, + "math mi": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "caption", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "bar" + } + ] + }, + { + "text": "baz" + } + ] + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "quux" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><caption><math><mi>foo</mi><mi>bar</mi>baz</math></caption></table><p>quux</p></body></html>", + "noQuirksBodyHtml": "<table><caption><math><mi>foo</mi><mi>bar</mi>baz</math></caption></table><p>quux</p>" + } + }, + { + "data": "<!DOCTYPE html><body><table><colgroup><math><mi>foo</mi><mi>bar</mi><p>baz</table><p>quux", + "errors": [ + "(1,44) foster-parenting-start-tag", + "(1,49) foster-parenting-character", + "(1,50) foster-parenting-character", + "(1,51) foster-parenting-character", + "(1,61) foster-parenting-character", + "(1,62) foster-parenting-character", + "(1,63) foster-parenting-character", + "(1,71) unexpected-html-element-in-foreign-content", + "(1,71) foster-parenting-start-tag", + "(1,63) foster-parenting-character", + "(1,63) foster-parenting-character", + "(1,63) foster-parenting-character" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mi": true, + "p": true, + "table": true, + "colgroup": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "bar" + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "baz" + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "colgroup" + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "quux" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math><mi>foo</mi><mi>bar</mi></math><p>baz</p><table><colgroup></colgroup></table><p>quux</p></body></html>", + "noQuirksBodyHtml": "<math><mi>foo</mi><mi>bar</mi><p>baz</p></math><table><colgroup></colgroup></table><p>quux</p>" + } + }, + { + "data": "<!DOCTYPE html><body><table><tr><td><select><math><mi>foo</mi><mi>bar</mi><p>baz</table><p>quux", + "errors": [ + "(1,50) unexpected-start-tag-in-select", + "(1,54) unexpected-start-tag-in-select", + "(1,62) unexpected-end-tag-in-select", + "(1,66) unexpected-start-tag-in-select", + "(1,74) unexpected-end-tag-in-select", + "(1,77) unexpected-start-tag-in-select", + "(1,88) unexpected-table-element-end-tag-in-select-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "select": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "select", + "children": [ + { + "text": "foobarbaz" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "quux" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><select>foobarbaz</select></td></tr></tbody></table><p>quux</p></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><select>foobarbaz</select></td></tr></tbody></table><p>quux</p>" + } + }, + { + "data": "<!DOCTYPE html><body><table><select><math><mi>foo</mi><mi>bar</mi><p>baz</table><p>quux", + "errors": [ + "(1,36) unexpected-start-tag-implies-table-voodoo", + "(1,42) unexpected-start-tag-in-select", + "(1,46) unexpected-start-tag-in-select", + "(1,54) unexpected-end-tag-in-select", + "(1,58) unexpected-start-tag-in-select", + "(1,66) unexpected-end-tag-in-select", + "(1,69) unexpected-start-tag-in-select", + "(1,80) unexpected-table-element-end-tag-in-select-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "table": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "text": "foobarbaz" + } + ] + }, + { + "tag": "table" + }, + { + "tag": "p", + "children": [ + { + "text": "quux" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><select>foobarbaz</select><table></table><p>quux</p></body></html>", + "noQuirksBodyHtml": "<select>foobarbaz</select><table></table><p>quux</p>" + } + }, + { + "data": "<!DOCTYPE html><body></body></html><math><mi>foo</mi><mi>bar</mi><p>baz", + "errors": [ + "(1,41) expected-eof-but-got-start-tag", + "(1,68) unexpected-html-element-in-foreign-content" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mi": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "bar" + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "baz" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math><mi>foo</mi><mi>bar</mi></math><p>baz</p></body></html>", + "noQuirksBodyHtml": "<math><mi>foo</mi><mi>bar</mi><p>baz</p></math>" + } + }, + { + "data": "<!DOCTYPE html><body></body><math><mi>foo</mi><mi>bar</mi><p>baz", + "errors": [ + "(1,34) unexpected-start-tag-after-body", + "(1,61) unexpected-html-element-in-foreign-content" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mi": true, + "p": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "bar" + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "text": "baz" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><math><mi>foo</mi><mi>bar</mi></math><p>baz</p></body></html>", + "noQuirksBodyHtml": "<math><mi>foo</mi><mi>bar</mi><p>baz</p></math>" + } + }, + { + "data": "<!DOCTYPE html><frameset><math><mi></mi><mi></mi><p><span>", + "errors": [ + "(1,31) unexpected-start-tag-in-frameset", + "(1,35) unexpected-start-tag-in-frameset", + "(1,40) unexpected-end-tag-in-frameset", + "(1,44) unexpected-start-tag-in-frameset", + "(1,49) unexpected-end-tag-in-frameset", + "(1,52) unexpected-start-tag-in-frameset", + "(1,58) unexpected-start-tag-in-frameset", + "(1,58) eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<math><mi></mi><mi></mi><p><span></span></p></math>" + } + }, + { + "data": "<!DOCTYPE html><frameset></frameset><math><mi></mi><mi></mi><p><span>", + "errors": [ + "(1,42) unexpected-start-tag-after-frameset", + "(1,46) unexpected-start-tag-after-frameset", + "(1,51) unexpected-end-tag-after-frameset", + "(1,55) unexpected-start-tag-after-frameset", + "(1,60) unexpected-end-tag-after-frameset", + "(1,63) unexpected-start-tag-after-frameset", + "(1,69) unexpected-start-tag-after-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<math><mi></mi><mi></mi><p><span></span></p></math>" + } + }, + { + "data": "<!DOCTYPE html><body xlink:href=foo><math xlink:href=foo></math>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "attrs": [ + { + "name": "xlink:href", + "value": "foo" + } + ], + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "attrs": [ + { + "name": "href", + "ns": "http://www.w3.org/1999/xlink", + "value": "foo" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body xlink:href=\"foo\"><math xlink:href=\"foo\"></math></body></html>", + "noQuirksBodyHtml": "<math xlink:href=\"foo\"></math>" + } + }, + { + "data": "<!DOCTYPE html><body xlink:href=foo xml:lang=en><math><mi xml:lang=en xlink:href=foo></mi></math>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mi": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "attrs": [ + { + "name": "xlink:href", + "value": "foo" + }, + { + "name": "xml:lang", + "value": "en" + } + ], + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "attrs": [ + { + "name": "href", + "ns": "http://www.w3.org/1999/xlink", + "value": "foo" + }, + { + "name": "lang", + "ns": "http://www.w3.org/XML/1998/namespace", + "value": "en" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body xlink:href=\"foo\" xml:lang=\"en\"><math><mi xml:lang=\"en\" xlink:href=\"foo\"></mi></math></body></html>", + "noQuirksBodyHtml": "<math><mi xml:lang=\"en\" xlink:href=\"foo\"></mi></math>" + } + }, + { + "data": "<!DOCTYPE html><body xlink:href=foo xml:lang=en><math><mi xml:lang=en xlink:href=foo /></math>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mi": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "attrs": [ + { + "name": "xlink:href", + "value": "foo" + }, + { + "name": "xml:lang", + "value": "en" + } + ], + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "attrs": [ + { + "name": "href", + "ns": "http://www.w3.org/1999/xlink", + "value": "foo" + }, + { + "name": "lang", + "ns": "http://www.w3.org/XML/1998/namespace", + "value": "en" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body xlink:href=\"foo\" xml:lang=\"en\"><math><mi xml:lang=\"en\" xlink:href=\"foo\"></mi></math></body></html>", + "noQuirksBodyHtml": "<math><mi xml:lang=\"en\" xlink:href=\"foo\"></mi></math>" + } + }, + { + "data": "<!DOCTYPE html><body xlink:href=foo xml:lang=en><math><mi xml:lang=en xlink:href=foo />bar</math>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mi": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "attrs": [ + { + "name": "xlink:href", + "value": "foo" + }, + { + "name": "xml:lang", + "value": "en" + } + ], + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "attrs": [ + { + "name": "href", + "ns": "http://www.w3.org/1999/xlink", + "value": "foo" + }, + { + "name": "lang", + "ns": "http://www.w3.org/XML/1998/namespace", + "value": "en" + } + ] + }, + { + "text": "bar" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body xlink:href=\"foo\" xml:lang=\"en\"><math><mi xml:lang=\"en\" xlink:href=\"foo\"></mi>bar</math></body></html>", + "noQuirksBodyHtml": "<math><mi xml:lang=\"en\" xlink:href=\"foo\"></mi>bar</math>" + } + } + ], + "tests_innerHTML_1.dat": [ + { + "data": "<body><span>", + "errors": [ + "(1,6): unexpected-start-tag", + "(1,12): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "body" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span" + } + ], + "html": "<span></span>", + "noQuirksBodyHtml": "<span></span>" + } + }, + { + "data": "<span><body>", + "errors": [ + "(1,12): unexpected-start-tag", + "(1,12): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "body" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span" + } + ], + "html": "<span></span>", + "noQuirksBodyHtml": "<span></span>" + } + }, + { + "data": "<span><body>", + "errors": [ + "(1,12): unexpected-start-tag", + "(1,12): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "div" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span" + } + ], + "html": "<span></span>", + "noQuirksBodyHtml": "<span></span>" + } + }, + { + "data": "<body><span>", + "errors": [ + "(1,12): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "html" + }, + "document": { + "props": { + "tags": { + "head": true, + "body": true, + "span": true + } + }, + "tree": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "span" + } + ] + } + ], + "html": "<head></head><body><span></span></body>", + "noQuirksBodyHtml": "<span></span>" + } + }, + { + "data": "<frameset><span>", + "errors": [ + "(1,10): unexpected-start-tag", + "(1,16): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "body" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span" + } + ], + "html": "<span></span>", + "noQuirksBodyHtml": "<span></span>" + } + }, + { + "data": "<span><frameset>", + "errors": [ + "(1,16): unexpected-start-tag", + "(1,16): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "body" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span" + } + ], + "html": "<span></span>", + "noQuirksBodyHtml": "<span></span>" + } + }, + { + "data": "<span><frameset>", + "errors": [ + "(1,16): unexpected-start-tag", + "(1,16): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "div" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span" + } + ], + "html": "<span></span>", + "noQuirksBodyHtml": "<span></span>" + } + }, + { + "data": "<frameset><span>", + "errors": [ + "(1,16): unexpected-start-tag-in-frameset", + "(1,16): eof-in-frameset" + ], + "fragment": { + "name": "html" + }, + "document": { + "props": { + "tags": { + "head": true, + "frameset": true + } + }, + "tree": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ], + "html": "<head></head><frameset></frameset>", + "noQuirksBodyHtml": "<span></span>" + } + }, + { + "data": "<table><tr>", + "errors": [ + "(1,7): unexpected-start-tag" + ], + "fragment": { + "name": "table" + }, + "document": { + "props": { + "tags": { + "tbody": true, + "tr": true + } + }, + "tree": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ], + "html": "<tbody><tr></tr></tbody>", + "noQuirksBodyHtml": "<table><tbody><tr></tr></tbody></table>" + } + }, + { + "data": "</table><tr>", + "errors": [ + "(1,8): unexpected-end-tag" + ], + "fragment": { + "name": "table" + }, + "document": { + "props": { + "tags": { + "tbody": true, + "tr": true + } + }, + "tree": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ], + "html": "<tbody><tr></tr></tbody>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<a>", + "errors": [ + "(1,3): unexpected-start-tag-implies-table-voodoo", + "(1,3): eof-in-table" + ], + "fragment": { + "name": "table" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<a>", + "errors": [ + "(1,3): unexpected-start-tag-implies-table-voodoo", + "(1,3): eof-in-table" + ], + "fragment": { + "name": "table" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<a><caption>a", + "errors": [ + "(1,3): unexpected-start-tag-implies-table-voodoo", + "(1,13): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "table" + }, + "document": { + "props": { + "tags": { + "a": true, + "caption": true + } + }, + "tree": [ + { + "tag": "a" + }, + { + "tag": "caption", + "children": [ + { + "text": "a" + } + ] + } + ], + "html": "<a></a><caption>a</caption>", + "noQuirksBodyHtml": "<a>a</a>" + } + }, + { + "data": "<a><colgroup><col>", + "errors": [ + "(1,3): foster-parenting-start-token", + "(1,18): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "table" + }, + "document": { + "props": { + "tags": { + "a": true, + "colgroup": true, + "col": true + } + }, + "tree": [ + { + "tag": "a" + }, + { + "tag": "colgroup", + "children": [ + { + "tag": "col" + } + ] + } + ], + "html": "<a></a><colgroup><col></colgroup>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<a><tbody><tr>", + "errors": [ + "(1,3): foster-parenting-start-tag" + ], + "fragment": { + "name": "table" + }, + "document": { + "props": { + "tags": { + "a": true, + "tbody": true, + "tr": true + } + }, + "tree": [ + { + "tag": "a" + }, + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ], + "html": "<a></a><tbody><tr></tr></tbody>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<a><tfoot><tr>", + "errors": [ + "(1,3): foster-parenting-start-tag" + ], + "fragment": { + "name": "table" + }, + "document": { + "props": { + "tags": { + "a": true, + "tfoot": true, + "tr": true + } + }, + "tree": [ + { + "tag": "a" + }, + { + "tag": "tfoot", + "children": [ + { + "tag": "tr" + } + ] + } + ], + "html": "<a></a><tfoot><tr></tr></tfoot>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<a><thead><tr>", + "errors": [ + "(1,3): foster-parenting-start-tag" + ], + "fragment": { + "name": "table" + }, + "document": { + "props": { + "tags": { + "a": true, + "thead": true, + "tr": true + } + }, + "tree": [ + { + "tag": "a" + }, + { + "tag": "thead", + "children": [ + { + "tag": "tr" + } + ] + } + ], + "html": "<a></a><thead><tr></tr></thead>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<a><tr>", + "errors": [ + "(1,3): foster-parenting-start-tag" + ], + "fragment": { + "name": "table" + }, + "document": { + "props": { + "tags": { + "a": true, + "tbody": true, + "tr": true + } + }, + "tree": [ + { + "tag": "a" + }, + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ], + "html": "<a></a><tbody><tr></tr></tbody>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<a><th>", + "errors": [ + "(1,3): unexpected-start-tag-implies-table-voodoo", + "(1,7): unexpected-cell-in-table-body" + ], + "fragment": { + "name": "table" + }, + "document": { + "props": { + "tags": { + "a": true, + "tbody": true, + "tr": true, + "th": true + } + }, + "tree": [ + { + "tag": "a" + }, + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "th" + } + ] + } + ] + } + ], + "html": "<a></a><tbody><tr><th></th></tr></tbody>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<a><td>", + "errors": [ + "(1,3): unexpected-start-tag-implies-table-voodoo", + "(1,7): unexpected-cell-in-table-body" + ], + "fragment": { + "name": "table" + }, + "document": { + "props": { + "tags": { + "a": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "a" + }, + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ], + "html": "<a></a><tbody><tr><td></td></tr></tbody>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<table></table><tbody>", + "errors": [ + "(1,22): unexpected-start-tag" + ], + "fragment": { + "name": "caption" + }, + "document": { + "props": { + "tags": { + "table": true + } + }, + "tree": [ + { + "tag": "table" + } + ], + "html": "<table></table>", + "noQuirksBodyHtml": "<table></table>" + } + }, + { + "data": "</table><span>", + "errors": [ + "(1,8): unexpected-end-tag", + "(1,14): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "caption" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span" + } + ], + "html": "<span></span>", + "noQuirksBodyHtml": "<span></span>" + } + }, + { + "data": "<span></table>", + "errors": [ + "(1,14): unexpected-end-tag", + "(1,14): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "caption" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span" + } + ], + "html": "<span></span>", + "noQuirksBodyHtml": "<span></span>" + } + }, + { + "data": "</caption><span>", + "errors": [ + "(1,10): XXX-undefined-error", + "(1,16): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "caption" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span" + } + ], + "html": "<span></span>", + "noQuirksBodyHtml": "<span></span>" + } + }, + { + "data": "<span></caption><span>", + "errors": [ + "(1,16): XXX-undefined-error", + "(1,22): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "caption" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span", + "children": [ + { + "tag": "span" + } + ] + } + ], + "html": "<span><span></span></span>", + "noQuirksBodyHtml": "<span><span></span></span>" + } + }, + { + "data": "<span><caption><span>", + "errors": [ + "(1,15): unexpected-start-tag", + "(1,21): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "caption" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span", + "children": [ + { + "tag": "span" + } + ] + } + ], + "html": "<span><span></span></span>", + "noQuirksBodyHtml": "<span><span></span></span>" + } + }, + { + "data": "<span><col><span>", + "errors": [ + "(1,11): unexpected-start-tag", + "(1,17): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "caption" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span", + "children": [ + { + "tag": "span" + } + ] + } + ], + "html": "<span><span></span></span>", + "noQuirksBodyHtml": "<span><span></span></span>" + } + }, + { + "data": "<span><colgroup><span>", + "errors": [ + "(1,16): unexpected-start-tag", + "(1,22): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "caption" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span", + "children": [ + { + "tag": "span" + } + ] + } + ], + "html": "<span><span></span></span>", + "noQuirksBodyHtml": "<span><span></span></span>" + } + }, + { + "data": "<span><html><span>", + "errors": [ + "(1,12): non-html-root", + "(1,18): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "caption" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span", + "children": [ + { + "tag": "span" + } + ] + } + ], + "html": "<span><span></span></span>", + "noQuirksBodyHtml": "<span><span></span></span>" + } + }, + { + "data": "<span><tbody><span>", + "errors": [ + "(1,13): unexpected-start-tag", + "(1,19): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "caption" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span", + "children": [ + { + "tag": "span" + } + ] + } + ], + "html": "<span><span></span></span>", + "noQuirksBodyHtml": "<span><span></span></span>" + } + }, + { + "data": "<span><td><span>", + "errors": [ + "(1,10): unexpected-start-tag", + "(1,16): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "caption" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span", + "children": [ + { + "tag": "span" + } + ] + } + ], + "html": "<span><span></span></span>", + "noQuirksBodyHtml": "<span><span></span></span>" + } + }, + { + "data": "<span><tfoot><span>", + "errors": [ + "(1,13): unexpected-start-tag", + "(1,19): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "caption" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span", + "children": [ + { + "tag": "span" + } + ] + } + ], + "html": "<span><span></span></span>", + "noQuirksBodyHtml": "<span><span></span></span>" + } + }, + { + "data": "<span><thead><span>", + "errors": [ + "(1,13): unexpected-start-tag", + "(1,19): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "caption" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span", + "children": [ + { + "tag": "span" + } + ] + } + ], + "html": "<span><span></span></span>", + "noQuirksBodyHtml": "<span><span></span></span>" + } + }, + { + "data": "<span><th><span>", + "errors": [ + "(1,10): unexpected-start-tag", + "(1,16): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "caption" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span", + "children": [ + { + "tag": "span" + } + ] + } + ], + "html": "<span><span></span></span>", + "noQuirksBodyHtml": "<span><span></span></span>" + } + }, + { + "data": "<span><tr><span>", + "errors": [ + "(1,10): unexpected-start-tag", + "(1,16): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "caption" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span", + "children": [ + { + "tag": "span" + } + ] + } + ], + "html": "<span><span></span></span>", + "noQuirksBodyHtml": "<span><span></span></span>" + } + }, + { + "data": "<span></table><span>", + "errors": [ + "(1,14): unexpected-end-tag", + "(1,20): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "caption" + }, + "document": { + "props": { + "tags": { + "span": true + } + }, + "tree": [ + { + "tag": "span", + "children": [ + { + "tag": "span" + } + ] + } + ], + "html": "<span><span></span></span>", + "noQuirksBodyHtml": "<span><span></span></span>" + } + }, + { + "data": "</colgroup><col>", + "errors": [ + "(1,11): XXX-undefined-error" + ], + "fragment": { + "name": "colgroup" + }, + "document": { + "props": { + "tags": { + "col": true + } + }, + "tree": [ + { + "tag": "col" + } + ], + "html": "<col>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<a><col>", + "errors": [ + "(1,3): XXX-undefined-error" + ], + "fragment": { + "name": "colgroup" + }, + "document": { + "props": { + "tags": { + "col": true + } + }, + "tree": [ + { + "tag": "col" + } + ], + "html": "<col>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<caption><a>", + "errors": [ + "(1,9): XXX-undefined-error", + "(1,12): unexpected-start-tag-implies-table-voodoo", + "(1,12): eof-in-table" + ], + "fragment": { + "name": "tbody" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<col><a>", + "errors": [ + "(1,5): XXX-undefined-error", + "(1,8): unexpected-start-tag-implies-table-voodoo", + "(1,8): eof-in-table" + ], + "fragment": { + "name": "tbody" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<colgroup><a>", + "errors": [ + "(1,10): XXX-undefined-error", + "(1,13): unexpected-start-tag-implies-table-voodoo", + "(1,13): eof-in-table" + ], + "fragment": { + "name": "tbody" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<tbody><a>", + "errors": [ + "(1,7): XXX-undefined-error", + "(1,10): unexpected-start-tag-implies-table-voodoo", + "(1,10): eof-in-table" + ], + "fragment": { + "name": "tbody" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<tfoot><a>", + "errors": [ + "(1,7): XXX-undefined-error", + "(1,10): unexpected-start-tag-implies-table-voodoo", + "(1,10): eof-in-table" + ], + "fragment": { + "name": "tbody" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<thead><a>", + "errors": [ + "(1,7): XXX-undefined-error", + "(1,10): unexpected-start-tag-implies-table-voodoo", + "(1,10): eof-in-table" + ], + "fragment": { + "name": "tbody" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "</table><a>", + "errors": [ + "(1,8): XXX-undefined-error", + "(1,11): unexpected-start-tag-implies-table-voodoo", + "(1,11): eof-in-table" + ], + "fragment": { + "name": "tbody" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<a><tr>", + "errors": [ + "(1,3): unexpected-start-tag-implies-table-voodoo" + ], + "fragment": { + "name": "tbody" + }, + "document": { + "props": { + "tags": { + "a": true, + "tr": true + } + }, + "tree": [ + { + "tag": "a" + }, + { + "tag": "tr" + } + ], + "html": "<a></a><tr></tr>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<a><td>", + "errors": [ + "(1,3): unexpected-start-tag-implies-table-voodoo", + "(1,7): unexpected-cell-in-table-body" + ], + "fragment": { + "name": "tbody" + }, + "document": { + "props": { + "tags": { + "a": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "a" + }, + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ], + "html": "<a></a><tr><td></td></tr>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<a><td>", + "errors": [ + "(1,3): unexpected-start-tag-implies-table-voodoo", + "(1,7): unexpected-cell-in-table-body" + ], + "fragment": { + "name": "tbody" + }, + "document": { + "props": { + "tags": { + "a": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "a" + }, + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ], + "html": "<a></a><tr><td></td></tr>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<a><td>", + "errors": [ + "(1,3): unexpected-start-tag-implies-table-voodoo", + "(1,7): unexpected-cell-in-table-body" + ], + "fragment": { + "name": "tbody" + }, + "document": { + "props": { + "tags": { + "a": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "a" + }, + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ], + "html": "<a></a><tr><td></td></tr>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<td><table><tbody><a><tr>", + "errors": [ + "(1,4): unexpected-cell-in-table-body", + "(1,21): unexpected-start-tag-implies-table-voodoo", + "(1,25): eof-in-table" + ], + "fragment": { + "name": "tbody" + }, + "document": { + "props": { + "tags": { + "tr": true, + "td": true, + "a": true, + "table": true, + "tbody": true + } + }, + "tree": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "a" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<tr><td><a></a><table><tbody><tr></tr></tbody></table></td></tr>", + "noQuirksBodyHtml": "<a></a><table><tbody><tr></tr></tbody></table>" + } + }, + { + "data": "</tr><td>", + "errors": [ + "(1,5): XXX-undefined-error" + ], + "fragment": { + "name": "tr" + }, + "document": { + "props": { + "tags": { + "td": true + } + }, + "tree": [ + { + "tag": "td" + } + ], + "html": "<td></td>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<td><table><a><tr></tr><tr>", + "errors": [ + "(1,14): unexpected-start-tag-implies-table-voodoo", + "(1,27): eof-in-table" + ], + "fragment": { + "name": "tr" + }, + "document": { + "props": { + "tags": { + "td": true, + "a": true, + "table": true, + "tbody": true, + "tr": true + } + }, + "tree": [ + { + "tag": "td", + "children": [ + { + "tag": "a" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + }, + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ], + "html": "<td><a></a><table><tbody><tr></tr><tr></tr></tbody></table></td>", + "noQuirksBodyHtml": "<a></a><table><tbody><tr></tr><tr></tr></tbody></table>" + } + }, + { + "data": "<caption><td>", + "errors": [ + "(1,9): XXX-undefined-error" + ], + "fragment": { + "name": "tr" + }, + "document": { + "props": { + "tags": { + "td": true + } + }, + "tree": [ + { + "tag": "td" + } + ], + "html": "<td></td>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<col><td>", + "errors": [ + "(1,5): XXX-undefined-error" + ], + "fragment": { + "name": "tr" + }, + "document": { + "props": { + "tags": { + "td": true + } + }, + "tree": [ + { + "tag": "td" + } + ], + "html": "<td></td>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<colgroup><td>", + "errors": [ + "(1,10): XXX-undefined-error" + ], + "fragment": { + "name": "tr" + }, + "document": { + "props": { + "tags": { + "td": true + } + }, + "tree": [ + { + "tag": "td" + } + ], + "html": "<td></td>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<tbody><td>", + "errors": [ + "(1,7): XXX-undefined-error" + ], + "fragment": { + "name": "tr" + }, + "document": { + "props": { + "tags": { + "td": true + } + }, + "tree": [ + { + "tag": "td" + } + ], + "html": "<td></td>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<tfoot><td>", + "errors": [ + "(1,7): XXX-undefined-error" + ], + "fragment": { + "name": "tr" + }, + "document": { + "props": { + "tags": { + "td": true + } + }, + "tree": [ + { + "tag": "td" + } + ], + "html": "<td></td>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<thead><td>", + "errors": [ + "(1,7): XXX-undefined-error" + ], + "fragment": { + "name": "tr" + }, + "document": { + "props": { + "tags": { + "td": true + } + }, + "tree": [ + { + "tag": "td" + } + ], + "html": "<td></td>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<tr><td>", + "errors": [ + "(1,4): XXX-undefined-error" + ], + "fragment": { + "name": "tr" + }, + "document": { + "props": { + "tags": { + "td": true + } + }, + "tree": [ + { + "tag": "td" + } + ], + "html": "<td></td>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "</table><td>", + "errors": [ + "(1,8): XXX-undefined-error" + ], + "fragment": { + "name": "tr" + }, + "document": { + "props": { + "tags": { + "td": true + } + }, + "tree": [ + { + "tag": "td" + } + ], + "html": "<td></td>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<td><table></table><td>", + "errors": [], + "fragment": { + "name": "tr" + }, + "document": { + "props": { + "tags": { + "td": true, + "table": true + } + }, + "tree": [ + { + "tag": "td", + "children": [ + { + "tag": "table" + } + ] + }, + { + "tag": "td" + } + ], + "html": "<td><table></table></td><td></td>", + "noQuirksBodyHtml": "<table></table>" + } + }, + { + "data": "<td><table></table><td>", + "errors": [], + "fragment": { + "name": "tr" + }, + "document": { + "props": { + "tags": { + "td": true, + "table": true + } + }, + "tree": [ + { + "tag": "td", + "children": [ + { + "tag": "table" + } + ] + }, + { + "tag": "td" + } + ], + "html": "<td><table></table></td><td></td>", + "noQuirksBodyHtml": "<table></table>" + } + }, + { + "data": "<caption><a>", + "errors": [ + "(1,9): XXX-undefined-error", + "(1,12): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "td" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<col><a>", + "errors": [ + "(1,5): XXX-undefined-error", + "(1,8): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "td" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<colgroup><a>", + "errors": [ + "(1,10): XXX-undefined-error", + "(1,13): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "td" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<tbody><a>", + "errors": [ + "(1,7): XXX-undefined-error", + "(1,10): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "td" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<tfoot><a>", + "errors": [ + "(1,7): XXX-undefined-error", + "(1,10): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "td" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<th><a>", + "errors": [ + "(1,4): XXX-undefined-error", + "(1,7): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "td" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<thead><a>", + "errors": [ + "(1,7): XXX-undefined-error", + "(1,10): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "td" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<tr><a>", + "errors": [ + "(1,4): XXX-undefined-error", + "(1,7): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "td" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "</table><a>", + "errors": [ + "(1,8): XXX-undefined-error", + "(1,11): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "td" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "</tbody><a>", + "errors": [ + "(1,8): XXX-undefined-error", + "(1,11): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "td" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "</td><a>", + "errors": [ + "(1,5): unexpected-end-tag", + "(1,8): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "td" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "</tfoot><a>", + "errors": [ + "(1,8): XXX-undefined-error", + "(1,11): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "td" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "</thead><a>", + "errors": [ + "(1,8): XXX-undefined-error", + "(1,11): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "td" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "</th><a>", + "errors": [ + "(1,5): unexpected-end-tag", + "(1,8): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "td" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "</tr><a>", + "errors": [ + "(1,5): XXX-undefined-error", + "(1,8): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "td" + }, + "document": { + "props": { + "tags": { + "a": true + } + }, + "tree": [ + { + "tag": "a" + } + ], + "html": "<a></a>", + "noQuirksBodyHtml": "<a></a>" + } + }, + { + "data": "<table><td><td>", + "errors": [ + "(1,11): unexpected-cell-in-table-body", + "(1,15): expected-closing-tag-but-got-eof" + ], + "fragment": { + "name": "td" + }, + "document": { + "props": { + "tags": { + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td" + }, + { + "tag": "td" + } + ] + } + ] + } + ] + } + ], + "html": "<table><tbody><tr><td></td><td></td></tr></tbody></table>", + "noQuirksBodyHtml": "<table><tbody><tr><td></td><td></td></tr></tbody></table>" + } + }, + { + "data": "</select><option>", + "errors": [ + "(1,9): XXX-undefined-error", + "(1,17): eof-in-select" + ], + "fragment": { + "name": "select" + }, + "document": { + "props": { + "tags": { + "option": true + } + }, + "tree": [ + { + "tag": "option" + } + ], + "html": "<option></option>", + "noQuirksBodyHtml": "<option></option>" + } + }, + { + "data": "<input><option>", + "errors": [ + "(1,7): unexpected-input-in-select", + "(1,15): eof-in-select" + ], + "fragment": { + "name": "select" + }, + "document": { + "props": { + "tags": { + "option": true + } + }, + "tree": [ + { + "tag": "option" + } + ], + "html": "<option></option>", + "noQuirksBodyHtml": "<input><option></option>" + } + }, + { + "data": "<keygen><option>", + "errors": [ + "(1,8): unexpected-input-in-select", + "(1,16): eof-in-select" + ], + "fragment": { + "name": "select" + }, + "document": { + "props": { + "tags": { + "option": true + } + }, + "tree": [ + { + "tag": "option" + } + ], + "html": "<option></option>", + "noQuirksBodyHtml": "<keygen><option></option>" + } + }, + { + "data": "<textarea><option>", + "errors": [ + "(1,10): unexpected-input-in-select", + "(1,18): eof-in-select" + ], + "fragment": { + "name": "select" + }, + "document": { + "props": { + "tags": { + "option": true + } + }, + "tree": [ + { + "tag": "option" + } + ], + "html": "<option></option>", + "noQuirksBodyHtml": "<textarea>&lt;option&gt;</textarea>" + } + }, + { + "data": "</html><!--abc-->", + "errors": [ + "(1,7): unexpected-end-tag-after-body-innerhtml" + ], + "fragment": { + "name": "html" + }, + "document": { + "props": { + "tags": { + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "head" + }, + { + "tag": "body" + }, + { + "comment": "abc" + } + ], + "html": "<head></head><body></body><!--abc-->", + "noQuirksBodyHtml": "<!--abc-->" + } + }, + { + "data": "</frameset><frame>", + "errors": [ + "(1,11): unexpected-frameset-in-frameset-innerhtml" + ], + "fragment": { + "name": "frameset" + }, + "document": { + "props": { + "tags": { + "frame": true + } + }, + "tree": [ + { + "tag": "frame" + } + ], + "html": "<frame>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "", + "errors": [], + "fragment": { + "name": "html" + }, + "document": { + "props": { + "tags": { + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ], + "html": "<head></head><body></body>", + "noQuirksBodyHtml": "" + } + } + ], + "tricky01.dat": [ + { + "data": "<b><p>Bold </b> Not bold</p>\nAlso not bold.", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,15): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b" + }, + { + "tag": "p", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "Bold " + } + ] + }, + { + "text": " Not bold" + } + ] + }, + { + "text": "\nAlso not bold." + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b></b><p><b>Bold </b> Not bold</p>\nAlso not bold.</body></html>", + "noQuirksBodyHtml": "<b></b><p><b>Bold </b> Not bold</p>\nAlso not bold." + } + }, + { + "data": "<html>\n<font color=red><i>Italic and Red<p>Italic and Red </font> Just italic.</p> Italic only.</i> Plain\n<p>I should not be red. <font color=red>Red. <i>Italic and red.</p>\n<p>Italic and red. </i> Red.</font> I should not be red.</p>\n<b>Bold <i>Bold and italic</b> Only Italic </i> Plain", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(2,58): adoption-agency-1.3", + "(3,67): unexpected-end-tag", + "(4,23): adoption-agency-1.3", + "(4,35): adoption-agency-1.3", + "(5,30): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "font": true, + "i": true, + "p": true, + "b": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "color", + "value": "red" + } + ], + "children": [ + { + "tag": "i", + "children": [ + { + "text": "Italic and Red" + } + ] + } + ] + }, + { + "tag": "i", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "color", + "value": "red" + } + ], + "children": [ + { + "text": "Italic and Red " + } + ] + }, + { + "text": " Just italic." + } + ] + }, + { + "text": " Italic only." + } + ] + }, + { + "text": " Plain\n" + }, + { + "tag": "p", + "children": [ + { + "text": "I should not be red. " + }, + { + "tag": "font", + "attrs": [ + { + "name": "color", + "value": "red" + } + ], + "children": [ + { + "text": "Red. " + }, + { + "tag": "i", + "children": [ + { + "text": "Italic and red." + } + ] + } + ] + } + ] + }, + { + "tag": "font", + "attrs": [ + { + "name": "color", + "value": "red" + } + ], + "children": [ + { + "tag": "i", + "children": [ + { + "text": "\n" + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "color", + "value": "red" + } + ], + "children": [ + { + "tag": "i", + "children": [ + { + "text": "Italic and red. " + } + ] + }, + { + "text": " Red." + } + ] + }, + { + "text": " I should not be red." + } + ] + }, + { + "text": "\n" + }, + { + "tag": "b", + "children": [ + { + "text": "Bold " + }, + { + "tag": "i", + "children": [ + { + "text": "Bold and italic" + } + ] + } + ] + }, + { + "tag": "i", + "children": [ + { + "text": " Only Italic " + } + ] + }, + { + "text": " Plain" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><font color=\"red\"><i>Italic and Red</i></font><i><p><font color=\"red\">Italic and Red </font> Just italic.</p> Italic only.</i> Plain\n<p>I should not be red. <font color=\"red\">Red. <i>Italic and red.</i></font></p><font color=\"red\"><i>\n</i></font><p><font color=\"red\"><i>Italic and red. </i> Red.</font> I should not be red.</p>\n<b>Bold <i>Bold and italic</i></b><i> Only Italic </i> Plain</body></html>", + "noQuirksBodyHtml": "\n<font color=\"red\"><i>Italic and Red</i></font><i><p><font color=\"red\">Italic and Red </font> Just italic.</p> Italic only.</i> Plain\n<p>I should not be red. <font color=\"red\">Red. <i>Italic and red.</i></font></p><font color=\"red\"><i>\n</i></font><p><font color=\"red\"><i>Italic and red. </i> Red.</font> I should not be red.</p>\n<b>Bold <i>Bold and italic</i></b><i> Only Italic </i> Plain" + } + }, + { + "data": "<html><body>\n<p><font size=\"7\">First paragraph.</p>\n<p>Second paragraph.</p></font>\n<b><p><i>Bold and Italic</b> Italic</p>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(2,38): unexpected-end-tag", + "(4,28): adoption-agency-1.3", + "(4,28): adoption-agency-1.3", + "(4,39): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "font": true, + "b": true, + "i": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "\n" + }, + { + "tag": "p", + "children": [ + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "7" + } + ], + "children": [ + { + "text": "First paragraph." + } + ] + } + ] + }, + { + "tag": "font", + "attrs": [ + { + "name": "size", + "value": "7" + } + ], + "children": [ + { + "text": "\n" + }, + { + "tag": "p", + "children": [ + { + "text": "Second paragraph." + } + ] + } + ] + }, + { + "text": "\n" + }, + { + "tag": "b" + }, + { + "tag": "p", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "i", + "children": [ + { + "text": "Bold and Italic" + } + ] + } + ] + }, + { + "tag": "i", + "children": [ + { + "text": " Italic" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body>\n<p><font size=\"7\">First paragraph.</font></p><font size=\"7\">\n<p>Second paragraph.</p></font>\n<b></b><p><b><i>Bold and Italic</i></b><i> Italic</i></p></body></html>", + "noQuirksBodyHtml": "\n<p><font size=\"7\">First paragraph.</font></p><font size=\"7\">\n<p>Second paragraph.</p></font>\n<b></b><p><b><i>Bold and Italic</i></b><i> Italic</i></p>" + } + }, + { + "data": "<html>\n<dl>\n<dt><b>Boo\n<dd>Goo?\n</dl>\n</html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(4,4): end-tag-too-early", + "(5,5): end-tag-too-early", + "(6,7): expected-one-end-tag-but-got-another" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "dl": true, + "dt": true, + "b": true, + "dd": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "dl", + "children": [ + { + "text": "\n" + }, + { + "tag": "dt", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "Boo\n" + } + ] + } + ] + }, + { + "tag": "dd", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "Goo?\n" + } + ] + } + ] + } + ] + }, + { + "tag": "b", + "children": [ + { + "text": "\n" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><dl>\n<dt><b>Boo\n</b></dt><dd><b>Goo?\n</b></dd></dl><b>\n</b></body></html>", + "noQuirksBodyHtml": "\n<dl>\n<dt><b>Boo\n</b></dt><dd><b>Goo?\n</b></dd></dl><b>\n</b>" + } + }, + { + "data": "<html><body>\n<label><a><div>Hello<div>World</div></a></label> \n</body></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(2,40): adoption-agency-1.3", + "(2,48): unexpected-end-tag", + "(3,7): expected-one-end-tag-but-got-another" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "label": true, + "a": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "\n" + }, + { + "tag": "label", + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "children": [ + { + "tag": "a", + "children": [ + { + "text": "Hello" + }, + { + "tag": "div", + "children": [ + { + "text": "World" + } + ] + } + ] + }, + { + "text": " \n" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body>\n<label><a></a><div><a>Hello<div>World</div></a> \n</div></label></body></html>", + "noQuirksBodyHtml": "\n<label><a></a><div><a>Hello<div>World</div></a> \n</div></label>" + } + }, + { + "data": "<table><center> <font>a</center> <img> <tr><td> </td> </tr> </table>", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,15): foster-parenting-start-tag", + "(1,16): foster-parenting-character", + "(1,22): foster-parenting-start-tag", + "(1,23): foster-parenting-character", + "(1,32): foster-parenting-end-tag", + "(1,32): end-tag-too-early", + "(1,33): foster-parenting-character", + "(1,38): foster-parenting-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "center": true, + "font": true, + "img": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "center", + "children": [ + { + "text": " " + }, + { + "tag": "font", + "children": [ + { + "text": "a" + } + ] + } + ] + }, + { + "tag": "font", + "children": [ + { + "tag": "img" + }, + { + "text": " " + } + ] + }, + { + "tag": "table", + "children": [ + { + "text": " " + }, + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "text": " " + } + ] + }, + { + "text": " " + } + ] + }, + { + "text": " " + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><center> <font>a</font></center><font><img> </font><table> <tbody><tr><td> </td> </tr> </tbody></table></body></html>", + "noQuirksBodyHtml": "<center> <font>a</font></center><font><img> </font><table> <tbody><tr><td> </td> </tr> </tbody></table>" + } + }, + { + "data": "<table><tr><p><a><p>You should see this text.", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,14): unexpected-start-tag-implies-table-voodoo", + "(1,17): unexpected-start-tag-implies-table-voodoo", + "(1,20): unexpected-start-tag-implies-table-voodoo", + "(1,20): closing-non-current-p-element", + "(1,21): foster-parenting-character", + "(1,22): foster-parenting-character", + "(1,23): foster-parenting-character", + "(1,24): foster-parenting-character", + "(1,25): foster-parenting-character", + "(1,26): foster-parenting-character", + "(1,27): foster-parenting-character", + "(1,28): foster-parenting-character", + "(1,29): foster-parenting-character", + "(1,30): foster-parenting-character", + "(1,31): foster-parenting-character", + "(1,32): foster-parenting-character", + "(1,33): foster-parenting-character", + "(1,34): foster-parenting-character", + "(1,35): foster-parenting-character", + "(1,36): foster-parenting-character", + "(1,37): foster-parenting-character", + "(1,38): foster-parenting-character", + "(1,39): foster-parenting-character", + "(1,40): foster-parenting-character", + "(1,41): foster-parenting-character", + "(1,42): foster-parenting-character", + "(1,43): foster-parenting-character", + "(1,44): foster-parenting-character", + "(1,45): foster-parenting-character", + "(1,45): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "a": true, + "table": true, + "tbody": true, + "tr": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "a" + } + ] + }, + { + "tag": "p", + "children": [ + { + "tag": "a", + "children": [ + { + "text": "You should see this text." + } + ] + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p><a></a></p><p><a>You should see this text.</a></p><table><tbody><tr></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<p><a></a></p><p><a>You should see this text.</a></p><table><tbody><tr></tr></tbody></table>" + } + }, + { + "data": "<TABLE>\n<TR>\n<CENTER><CENTER><TD></TD></TR><TR>\n<FONT>\n<TABLE><tr></tr></TABLE>\n</P>\n<a></font><font></a>\nThis page contains an insanely badly-nested tag sequence.", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(3,8): unexpected-start-tag-implies-table-voodoo", + "(3,16): unexpected-start-tag-implies-table-voodoo", + "(4,6): unexpected-start-tag-implies-table-voodoo", + "(4,6): unexpected character token in table (the newline)", + "(5,7): unexpected-start-tag-implies-end-tag", + "(6,4): unexpected p end tag", + "(7,10): adoption-agency-1.3", + "(7,20): adoption-agency-1.3", + "(8,57): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "center": true, + "font": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "p": true, + "a": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "center", + "children": [ + { + "tag": "center" + } + ] + }, + { + "tag": "font", + "children": [ + { + "text": "\n" + } + ] + }, + { + "tag": "table", + "children": [ + { + "text": "\n" + }, + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "text": "\n" + }, + { + "tag": "td" + } + ] + }, + { + "tag": "tr", + "children": [ + { + "text": "\n" + } + ] + } + ] + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + }, + { + "tag": "font", + "children": [ + { + "text": "\n" + }, + { + "tag": "p" + }, + { + "text": "\n" + }, + { + "tag": "a" + } + ] + }, + { + "tag": "a", + "children": [ + { + "tag": "font" + } + ] + }, + { + "tag": "font", + "children": [ + { + "text": "\nThis page contains an insanely badly-nested tag sequence." + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><center><center></center></center><font>\n</font><table>\n<tbody><tr>\n<td></td></tr><tr>\n</tr></tbody></table><table><tbody><tr></tr></tbody></table><font>\n<p></p>\n<a></a></font><a><font></font></a><font>\nThis page contains an insanely badly-nested tag sequence.</font></body></html>", + "noQuirksBodyHtml": "<center><center></center></center><font>\n</font><table>\n<tbody><tr>\n<td></td></tr><tr>\n</tr></tbody></table><table><tbody><tr></tr></tbody></table><font>\n<p></p>\n<a></a></font><a><font></font></a><font>\nThis page contains an insanely badly-nested tag sequence.</font>" + } + }, + { + "data": "<html>\n<body>\n<b><nobr><div>This text is in a div inside a nobr</nobr>More text that should not be in the nobr, i.e., the\nnobr should have closed the div inside it implicitly. </b><pre>A pre tag outside everything else.</pre>\n</body>\n</html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(3,56): adoption-agency-1.3", + "(4,58): adoption-agency-1.3", + "(5,7): expected-one-end-tag-but-got-another" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "nobr": true, + "div": true, + "pre": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "\n" + }, + { + "tag": "b", + "children": [ + { + "tag": "nobr" + } + ] + }, + { + "tag": "div", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "nobr", + "children": [ + { + "text": "This text is in a div inside a nobr" + } + ] + }, + { + "text": "More text that should not be in the nobr, i.e., the\nnobr should have closed the div inside it implicitly. " + } + ] + }, + { + "tag": "pre", + "children": [ + { + "text": "A pre tag outside everything else." + } + ] + }, + { + "text": "\n\n" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body>\n<b><nobr></nobr></b><div><b><nobr>This text is in a div inside a nobr</nobr>More text that should not be in the nobr, i.e., the\nnobr should have closed the div inside it implicitly. </b><pre>A pre tag outside everything else.</pre>\n\n</div></body></html>", + "noQuirksBodyHtml": "\n\n<b><nobr></nobr></b><div><b><nobr>This text is in a div inside a nobr</nobr>More text that should not be in the nobr, i.e., the\nnobr should have closed the div inside it implicitly. </b><pre>A pre tag outside everything else.</pre>\n\n</div>" + } + } + ], + "webkit01.dat": [ + { + "data": "Test", + "errors": [ + "(1,4): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "Test" + } + ] + } + ] + } + ], + "html": "<html><head></head><body>Test</body></html>", + "noQuirksBodyHtml": "Test" + } + }, + { + "data": "<div></div>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div></div></body></html>", + "noQuirksBodyHtml": "<div></div>" + } + }, + { + "data": "<div>Test</div>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "Test" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div>Test</div></body></html>", + "noQuirksBodyHtml": "<div>Test</div>" + } + }, + { + "data": "<di", + "errors": [ + "(1,3): eof-in-tag-name", + "(1,3): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<div>Hello</div>\n<script>\nconsole.log(\"PASS\");\n</script>\n<div>Bye</div>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "Hello" + } + ] + }, + { + "text": "\n" + }, + { + "tag": "script", + "children": [ + { + "text": "\nconsole.log(\"PASS\");\n", + "no_escape": true + } + ] + }, + { + "text": "\n" + }, + { + "tag": "div", + "children": [ + { + "text": "Bye" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div>Hello</div>\n<script>\nconsole.log(\"PASS\");\n</script>\n<div>Bye</div></body></html>", + "noQuirksBodyHtml": "<div>Hello</div>\n<script>\nconsole.log(\"PASS\");\n</script>\n<div>Bye</div>" + } + }, + { + "data": "<div foo=\"bar\">Hello</div>", + "errors": [ + "(1,15): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "foo", + "value": "bar" + } + ], + "children": [ + { + "text": "Hello" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div foo=\"bar\">Hello</div></body></html>", + "noQuirksBodyHtml": "<div foo=\"bar\">Hello</div>" + } + }, + { + "data": "<div>Hello</div>\n<script>\nconsole.log(\"FOO<span>BAR</span>BAZ\");\n</script>\n<div>Bye</div>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "script": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "Hello" + } + ] + }, + { + "text": "\n" + }, + { + "tag": "script", + "children": [ + { + "text": "\nconsole.log(\"FOO<span>BAR</span>BAZ\");\n", + "no_escape": true + } + ] + }, + { + "text": "\n" + }, + { + "tag": "div", + "children": [ + { + "text": "Bye" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div>Hello</div>\n<script>\nconsole.log(\"FOO<span>BAR</span>BAZ\");\n</script>\n<div>Bye</div></body></html>", + "noQuirksBodyHtml": "<div>Hello</div>\n<script>\nconsole.log(\"FOO<span>BAR</span>BAZ\");\n</script>\n<div>Bye</div>" + } + }, + { + "data": "<foo bar=\"baz\"></foo><potato quack=\"duck\"></potato>", + "errors": [ + "(1,15): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "foo": true, + "potato": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "foo", + "attrs": [ + { + "name": "bar", + "value": "baz" + } + ] + }, + { + "tag": "potato", + "attrs": [ + { + "name": "quack", + "value": "duck" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><foo bar=\"baz\"></foo><potato quack=\"duck\"></potato></body></html>", + "noQuirksBodyHtml": "<foo bar=\"baz\"></foo><potato quack=\"duck\"></potato>" + } + }, + { + "data": "<foo bar=\"baz\"><potato quack=\"duck\"></potato></foo>", + "errors": [ + "(1,15): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "foo": true, + "potato": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "foo", + "attrs": [ + { + "name": "bar", + "value": "baz" + } + ], + "children": [ + { + "tag": "potato", + "attrs": [ + { + "name": "quack", + "value": "duck" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><foo bar=\"baz\"><potato quack=\"duck\"></potato></foo></body></html>", + "noQuirksBodyHtml": "<foo bar=\"baz\"><potato quack=\"duck\"></potato></foo>" + } + }, + { + "data": "<foo></foo bar=\"baz\"><potato></potato quack=\"duck\">", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,21): attributes-in-end-tag", + "(1,51): attributes-in-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "foo": true, + "potato": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "foo" + }, + { + "tag": "potato" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><foo></foo><potato></potato></body></html>", + "noQuirksBodyHtml": "<foo></foo><potato></potato>" + } + }, + { + "data": "</ tttt>", + "errors": [ + "(1,2): expected-closing-tag-but-got-char", + "(1,8): expected-doctype-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "comment": " tttt" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<!-- tttt--><html><head></head><body></body></html>", + "noQuirksBodyHtml": "<!-- tttt-->" + } + }, + { + "data": "<div FOO ><img><img></div>", + "errors": [ + "(1,10): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "img": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "foo", + "value": "" + } + ], + "children": [ + { + "tag": "img" + }, + { + "tag": "img" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div foo=\"\"><img><img></div></body></html>", + "noQuirksBodyHtml": "<div foo=\"\"><img><img></div>" + } + }, + { + "data": "<p>Test</p<p>Test2</p>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,13): unexpected-end-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "text": "TestTest2" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p>TestTest2</p></body></html>", + "noQuirksBodyHtml": "<p>TestTest2</p>" + } + }, + { + "data": "<rdar://problem/6869687>", + "errors": [ + "(1,7): unexpected-character-after-solidus-in-tag", + "(1,8): unexpected-character-after-solidus-in-tag", + "(1,16): unexpected-character-after-solidus-in-tag", + "(1,24): expected-doctype-but-got-start-tag", + "(1,24): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "rdar:": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "rdar:", + "attrs": [ + { + "name": "6869687", + "value": "" + }, + { + "name": "problem", + "value": "" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><rdar: problem=\"\" 6869687=\"\"></rdar:></body></html>", + "noQuirksBodyHtml": "<rdar: problem=\"\" 6869687=\"\"></rdar:>" + } + }, + { + "data": "<A>test< /A>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,8): expected-tag-name", + "(1,12): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "children": [ + { + "text": "test< /A>", + "escaped": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a>test&lt; /A&gt;</a></body></html>", + "noQuirksBodyHtml": "<a>test&lt; /A&gt;</a>" + } + }, + { + "data": "&lt;", + "errors": [ + "(1,4): expected-doctype-but-got-chars" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "escaped": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "<", + "escaped": true + } + ] + } + ] + } + ], + "html": "<html><head></head><body>&lt;</body></html>", + "noQuirksBodyHtml": "&lt;" + } + }, + { + "data": "<body foo='bar'><body foo='baz' yo='mama'>", + "errors": [ + "(1,16): expected-doctype-but-got-start-tag", + "(1,42): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "attrs": [ + { + "name": "foo", + "value": "bar" + }, + { + "name": "yo", + "value": "mama" + } + ] + } + ] + } + ], + "html": "<html><head></head><body foo=\"bar\" yo=\"mama\"></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<body></br foo=\"bar\"></body>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,21): attributes-in-end-tag", + "(1,21): unexpected-end-tag-treated-as" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "br": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "br" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><br></body></html>", + "noQuirksBodyHtml": "<br>" + } + }, + { + "data": "<bdy><br foo=\"bar\"></body>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,26): expected-one-end-tag-but-got-another" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "bdy": true, + "br": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "bdy", + "children": [ + { + "tag": "br", + "attrs": [ + { + "name": "foo", + "value": "bar" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><bdy><br foo=\"bar\"></bdy></body></html>", + "noQuirksBodyHtml": "<bdy><br foo=\"bar\"></bdy>" + } + }, + { + "data": "<body></body></br foo=\"bar\">", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,28): attributes-in-end-tag", + "(1,28): unexpected-end-tag-after-body", + "(1,28): unexpected-end-tag-treated-as" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "br": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "br" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><br></body></html>", + "noQuirksBodyHtml": "<br>" + } + }, + { + "data": "<bdy></body><br foo=\"bar\">", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,12): expected-one-end-tag-but-got-another", + "(1,26): unexpected-start-tag-after-body", + "(1,26): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "bdy": true, + "br": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "bdy", + "children": [ + { + "tag": "br", + "attrs": [ + { + "name": "foo", + "value": "bar" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><bdy><br foo=\"bar\"></bdy></body></html>", + "noQuirksBodyHtml": "<bdy><br foo=\"bar\"></bdy>" + } + }, + { + "data": "<html><body></body></html><!-- Hi there -->", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + }, + { + "comment": " Hi there " + } + ], + "html": "<html><head></head><body></body></html><!-- Hi there -->", + "noQuirksBodyHtml": "<!-- Hi there -->" + } + }, + { + "data": "<html><body></body></html>x<!-- Hi there -->", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,27): expected-eof-but-got-char" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "x" + }, + { + "comment": " Hi there " + } + ] + } + ] + } + ], + "html": "<html><head></head><body>x<!-- Hi there --></body></html>", + "noQuirksBodyHtml": "x<!-- Hi there -->" + } + }, + { + "data": "<html><body></body></html>x<!-- Hi there --></html><!-- Again -->", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,27): expected-eof-but-got-char" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "x" + }, + { + "comment": " Hi there " + } + ] + } + ] + }, + { + "comment": " Again " + } + ], + "html": "<html><head></head><body>x<!-- Hi there --></body></html><!-- Again -->", + "noQuirksBodyHtml": "x<!-- Hi there --><!-- Again -->" + } + }, + { + "data": "<html><body></body></html>x<!-- Hi there --></body></html><!-- Again -->", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,27): expected-eof-but-got-char" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + }, + "comment": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "x" + }, + { + "comment": " Hi there " + } + ] + } + ] + }, + { + "comment": " Again " + } + ], + "html": "<html><head></head><body>x<!-- Hi there --></body></html><!-- Again -->", + "noQuirksBodyHtml": "x<!-- Hi there --><!-- Again -->" + } + }, + { + "data": "<html><body><ruby><div><rp>xx</rp></div></ruby></body></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,27): XXX-undefined-error" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "div": true, + "rp": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "rp", + "children": [ + { + "text": "xx" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby><div><rp>xx</rp></div></ruby></body></html>", + "noQuirksBodyHtml": "<ruby><div><rp>xx</rp></div></ruby>" + } + }, + { + "data": "<html><body><ruby><div><rt>xx</rt></div></ruby></body></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,27): XXX-undefined-error" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ruby": true, + "div": true, + "rt": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ruby", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "rt", + "children": [ + { + "text": "xx" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ruby><div><rt>xx</rt></div></ruby></body></html>", + "noQuirksBodyHtml": "<ruby><div><rt>xx</rt></div></ruby>" + } + }, + { + "data": "<html><frameset><!--1--><noframes>A</noframes><!--2--></frameset><!--3--><noframes>B</noframes><!--4--></html><!--5--><noframes>C</noframes><!--6-->", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true, + "noframes": true + }, + "comment": true, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset", + "children": [ + { + "comment": "1" + }, + { + "tag": "noframes", + "children": [ + { + "text": "A", + "no_escape": true + } + ] + }, + { + "comment": "2" + } + ] + }, + { + "comment": "3" + }, + { + "tag": "noframes", + "children": [ + { + "text": "B", + "no_escape": true + } + ] + }, + { + "comment": "4" + }, + { + "tag": "noframes", + "children": [ + { + "text": "C", + "no_escape": true + } + ] + } + ] + }, + { + "comment": "5" + }, + { + "comment": "6" + } + ], + "html": "<html><head></head><frameset><!--1--><noframes>A</noframes><!--2--></frameset><!--3--><noframes>B</noframes><!--4--><noframes>C</noframes></html><!--5--><!--6-->", + "noQuirksBodyHtml": "<!--1--><noframes>A</noframes><!--2--><!--3--><noframes>B</noframes><!--4--><!--5--><noframes>C</noframes><!--6-->" + } + }, + { + "data": "<select><option>A<select><option>B<select><option>C<select><option>D<select><option>E<select><option>F<select><option>G<select>", + "errors": [ + "(1,8): expected-doctype-but-got-start-tag", + "(1,25): unexpected-select-in-select", + "(1,59): unexpected-select-in-select", + "(1,93): unexpected-select-in-select", + "(1,127): unexpected-select-in-select", + "(1,127): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "select": true, + "option": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "select", + "children": [ + { + "tag": "option", + "children": [ + { + "text": "A" + } + ] + } + ] + }, + { + "tag": "option", + "children": [ + { + "text": "B" + }, + { + "tag": "select", + "children": [ + { + "tag": "option", + "children": [ + { + "text": "C" + } + ] + } + ] + } + ] + }, + { + "tag": "option", + "children": [ + { + "text": "D" + }, + { + "tag": "select", + "children": [ + { + "tag": "option", + "children": [ + { + "text": "E" + } + ] + } + ] + } + ] + }, + { + "tag": "option", + "children": [ + { + "text": "F" + }, + { + "tag": "select", + "children": [ + { + "tag": "option", + "children": [ + { + "text": "G" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><select><option>A</option></select><option>B<select><option>C</option></select></option><option>D<select><option>E</option></select></option><option>F<select><option>G</option></select></option></body></html>", + "noQuirksBodyHtml": "<select><option>A</option></select><option>B<select><option>C</option></select></option><option>D<select><option>E</option></select></option><option>F<select><option>G</option></select></option>" + } + }, + { + "data": "<dd><dd><dt><dt><dd><li><li>", + "errors": [ + "(1,4): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "dd": true, + "dt": true, + "li": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "dd" + }, + { + "tag": "dd" + }, + { + "tag": "dt" + }, + { + "tag": "dt" + }, + { + "tag": "dd", + "children": [ + { + "tag": "li" + }, + { + "tag": "li" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><dd></dd><dd></dd><dt></dt><dt></dt><dd><li></li><li></li></dd></body></html>", + "noQuirksBodyHtml": "<dd></dd><dd></dd><dt></dt><dt></dt><dd><li></li><li></li></dd>" + } + }, + { + "data": "<div><b></div><div><nobr>a<nobr>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,14): end-tag-too-early", + "(1,32): unexpected-start-tag-implies-end-tag", + "(1,32): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "b": true, + "nobr": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "b" + } + ] + }, + { + "tag": "div", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "nobr", + "children": [ + { + "text": "a" + } + ] + }, + { + "tag": "nobr" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div><b></b></div><div><b><nobr>a</nobr><nobr></nobr></b></div></body></html>", + "noQuirksBodyHtml": "<div><b></b></div><div><b><nobr>a</nobr><nobr></nobr></b></div>" + } + }, + { + "data": "<head></head>\n<body></body>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "text": "\n" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head>\n<body></body></html>", + "noQuirksBodyHtml": "\n" + } + }, + { + "data": "<head></head> <style></style>ddd", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,21): unexpected-start-tag-out-of-my-head" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "style": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head", + "children": [ + { + "tag": "style" + } + ] + }, + { + "text": " " + }, + { + "tag": "body", + "children": [ + { + "text": "ddd" + } + ] + } + ] + } + ], + "html": "<html><head><style></style></head> <body>ddd</body></html>", + "noQuirksBodyHtml": " <style></style>ddd" + } + }, + { + "data": "<kbd><table></kbd><col><select><tr>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,18): unexpected-end-tag-implies-table-voodoo", + "(1,18): unexpected-end-tag", + "(1,31): unexpected-start-tag-implies-table-voodoo", + "(1,35): unexpected-table-element-start-tag-in-select-in-table", + "(1,35): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "kbd": true, + "select": true, + "table": true, + "colgroup": true, + "col": true, + "tbody": true, + "tr": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "kbd", + "children": [ + { + "tag": "select" + }, + { + "tag": "table", + "children": [ + { + "tag": "colgroup", + "children": [ + { + "tag": "col" + } + ] + }, + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><kbd><select></select><table><colgroup><col></colgroup><tbody><tr></tr></tbody></table></kbd></body></html>", + "noQuirksBodyHtml": "<kbd><select></select><table><colgroup><col></colgroup><tbody><tr></tr></tbody></table></kbd>" + } + }, + { + "data": "<kbd><table></kbd><col><select><tr></table><div>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,18): unexpected-end-tag-implies-table-voodoo", + "(1,18): unexpected-end-tag", + "(1,31): unexpected-start-tag-implies-table-voodoo", + "(1,35): unexpected-table-element-start-tag-in-select-in-table", + "(1,48): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "kbd": true, + "select": true, + "table": true, + "colgroup": true, + "col": true, + "tbody": true, + "tr": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "kbd", + "children": [ + { + "tag": "select" + }, + { + "tag": "table", + "children": [ + { + "tag": "colgroup", + "children": [ + { + "tag": "col" + } + ] + }, + { + "tag": "tbody", + "children": [ + { + "tag": "tr" + } + ] + } + ] + }, + { + "tag": "div" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><kbd><select></select><table><colgroup><col></colgroup><tbody><tr></tr></tbody></table><div></div></kbd></body></html>", + "noQuirksBodyHtml": "<kbd><select></select><table><colgroup><col></colgroup><tbody><tr></tr></tbody></table><div></div></kbd>" + } + }, + { + "data": "<a><li><style></style><title></title></a>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,41): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "li": true, + "style": true, + "title": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a" + }, + { + "tag": "li", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "style" + }, + { + "tag": "title" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a></a><li><a><style></style><title></title></a></li></body></html>", + "noQuirksBodyHtml": "<a></a><li><a><style></style><title></title></a></li>" + } + }, + { + "data": "<font></p><p><meta><title></title></font>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,10): unexpected-end-tag", + "(1,41): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "font": true, + "p": true, + "meta": true, + "title": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "font", + "children": [ + { + "tag": "p" + } + ] + }, + { + "tag": "p", + "children": [ + { + "tag": "font", + "children": [ + { + "tag": "meta" + }, + { + "tag": "title" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><font><p></p></font><p><font><meta><title></title></font></p></body></html>", + "noQuirksBodyHtml": "<font><p></p></font><p><font><meta><title></title></font></p>" + } + }, + { + "data": "<a><center><title></title><a>", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,29): unexpected-start-tag-implies-end-tag", + "(1,29): adoption-agency-1.3", + "(1,29): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "center": true, + "title": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a" + }, + { + "tag": "center", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "title" + } + ] + }, + { + "tag": "a" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><a></a><center><a><title></title></a><a></a></center></body></html>", + "noQuirksBodyHtml": "<a></a><center><a><title></title></a><a></a></center>" + } + }, + { + "data": "<svg><title><div>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,17): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg title": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "title", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg><title><div></div></title></svg></body></html>", + "noQuirksBodyHtml": "<svg><title><div></div></title></svg>" + } + }, + { + "data": "<svg><title><rect><div>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,23): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg title": true, + "rect": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "title", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "rect", + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg><title><rect><div></div></rect></title></svg></body></html>", + "noQuirksBodyHtml": "<svg><title><rect><div></div></rect></title></svg>" + } + }, + { + "data": "<svg><title><svg><div>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,22): unexpected-html-element-in-foreign-content", + "(1,22): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg title": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "title", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg><title><svg></svg><div></div></title></svg></body></html>", + "noQuirksBodyHtml": "<svg><title><svg><div></div></svg></title></svg>" + } + }, + { + "data": "<img <=\"\" FAIL>", + "errors": [ + "(1,6): invalid-character-in-attribute-name", + "(1,15): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "img": true + }, + "attrWithFunnyChar": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "img", + "attrs": [ + { + "name": "<", + "value": "" + }, + { + "name": "fail", + "value": "" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><img <=\"\" fail=\"\"></body></html>", + "noQuirksBodyHtml": "<img <=\"\" fail=\"\">" + } + }, + { + "data": "<ul><li><div id='foo'/>A</li><li>B<div>C</div></li></ul>", + "errors": [ + "(1,4): expected-doctype-but-got-start-tag", + "(1,23): non-void-element-with-trailing-solidus", + "(1,29): end-tag-too-early" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "ul": true, + "li": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "ul", + "children": [ + { + "tag": "li", + "children": [ + { + "tag": "div", + "attrs": [ + { + "name": "id", + "value": "foo" + } + ], + "children": [ + { + "text": "A" + } + ] + } + ] + }, + { + "tag": "li", + "children": [ + { + "text": "B" + }, + { + "tag": "div", + "children": [ + { + "text": "C" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><ul><li><div id=\"foo\">A</div></li><li>B<div>C</div></li></ul></body></html>", + "noQuirksBodyHtml": "<ul><li><div id=\"foo\">A</div></li><li>B<div>C</div></li></ul>" + } + }, + { + "data": "<svg><em><desc></em>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,9): unexpected-html-element-in-foreign-content", + "(1,20): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "em": true, + "desc": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "em", + "children": [ + { + "tag": "desc" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg></svg><em><desc></desc></em></body></html>", + "noQuirksBodyHtml": "<svg><em><desc></desc></em></svg>" + } + }, + { + "data": "<table><tr><td><svg><desc><td></desc><circle>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true, + "svg svg": true, + "svg desc": true, + "circle": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "desc", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + }, + { + "tag": "td", + "children": [ + { + "tag": "circle" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><td><svg><desc></desc></svg></td><td><circle></circle></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td><svg><desc></desc></svg></td><td><circle></circle></td></tr></tbody></table>" + } + }, + { + "data": "<svg><tfoot></mi><td>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,17): unexpected-end-tag", + "(1,17): unexpected-end-tag", + "(1,21): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg tfoot": true, + "svg td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "tfoot", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "td", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg><tfoot><td></td></tfoot></svg></body></html>", + "noQuirksBodyHtml": "<svg><tfoot><td></td></tfoot></svg>" + } + }, + { + "data": "<math><mrow><mrow><mn>1</mn></mrow><mi>a</mi></mrow></math>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "math math": true, + "math mrow": true, + "math mn": true, + "math mi": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "math", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mrow", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mrow", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "tag": "mn", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "1" + } + ] + } + ] + }, + { + "tag": "mi", + "ns": "http://www.w3.org/1998/Math/MathML", + "children": [ + { + "text": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><math><mrow><mrow><mn>1</mn></mrow><mi>a</mi></mrow></math></body></html>", + "noQuirksBodyHtml": "<math><mrow><mrow><mn>1</mn></mrow><mi>a</mi></mrow></math>" + } + }, + { + "data": "<!doctype html><input type=\"hidden\"><frameset>", + "errors": [ + "(1,46): unexpected-start-tag", + "(1,46): eof-in-frameset" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "frameset": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "frameset" + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>", + "noQuirksBodyHtml": "<input type=\"hidden\">" + } + }, + { + "data": "<!doctype html><input type=\"button\"><frameset>", + "errors": [ + "(1,46): unexpected-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "input": true + }, + "doctype": true + }, + "tree": [ + { + "doctype": "html" + }, + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "input", + "attrs": [ + { + "name": "type", + "value": "button" + } + ] + } + ] + } + ] + } + ], + "html": "<!DOCTYPE html><html><head></head><body><input type=\"button\"></body></html>", + "noQuirksBodyHtml": "<input type=\"button\">" + } + } + ], + "webkit02.dat": [ + { + "data": "<foo bar=qux/>", + "errors": [ + "(1,14): expected-doctype-but-got-start-tag", + "(1,14): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "foo": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "foo", + "attrs": [ + { + "name": "bar", + "value": "qux/" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><foo bar=\"qux/\"></foo></body></html>", + "noQuirksBodyHtml": "<foo bar=\"qux/\"></foo>" + } + }, + { + "data": "<p id=\"status\"><noscript><strong>A</strong></noscript><span>B</span></p>", + "errors": [ + "(1,15): expected-doctype-but-got-start-tag" + ], + "script": "on", + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "noscript": true, + "span": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "attrs": [ + { + "name": "id", + "value": "status" + } + ], + "children": [ + { + "tag": "noscript", + "children": [ + { + "text": "<strong>A</strong>", + "no_escape": true + } + ] + }, + { + "tag": "span", + "children": [ + { + "text": "B" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p id=\"status\"><noscript><strong>A</strong></noscript><span>B</span></p></body></html>", + "noQuirksBodyHtml": "<p id=\"status\"><noscript>&lt;strong&gt;A&lt;/strong&gt;</noscript><span>B</span></p>" + } + }, + { + "data": "<p id=\"status\"><noscript><strong>A</strong></noscript><span>B</span></p>", + "errors": [ + "(1,15): expected-doctype-but-got-start-tag" + ], + "script": "off", + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "noscript": true, + "strong": true, + "span": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "attrs": [ + { + "name": "id", + "value": "status" + } + ], + "children": [ + { + "tag": "noscript", + "children": [ + { + "tag": "strong", + "children": [ + { + "text": "A" + } + ] + } + ] + }, + { + "tag": "span", + "children": [ + { + "text": "B" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><p id=\"status\"><noscript><strong>A</strong></noscript><span>B</span></p></body></html>", + "noQuirksBodyHtml": "<p id=\"status\"><noscript>&lt;strong&gt;A&lt;/strong&gt;</noscript><span>B</span></p>" + } + }, + { + "data": "<div><sarcasm><div></div></sarcasm></div>", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "sarcasm": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "sarcasm", + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><div><sarcasm><div></div></sarcasm></div></body></html>", + "noQuirksBodyHtml": "<div><sarcasm><div></div></sarcasm></div>" + } + }, + { + "data": "<html><body><img src=\"\" border=\"0\" alt=\"><div>A</div></body></html>", + "errors": [ + "(1,6): expected-doctype-but-got-start-tag", + "(1,67): eof-in-attribute-value-double-quote" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body" + } + ] + } + ], + "html": "<html><head></head><body></body></html>", + "noQuirksBodyHtml": "" + } + }, + { + "data": "<table><td></tbody>A", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,11): unexpected-cell-in-table-body", + "(1,20): foster-parenting-character", + "(1,20): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "A" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body>A<table><tbody><tr><td></td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "A<table><tbody><tr><td></td></tr></tbody></table>" + } + }, + { + "data": "<table><td></thead>A", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,11): unexpected-cell-in-table-body", + "(1,19): XXX-undefined-error", + "(1,20): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "text": "A" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><td>A</td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td>A</td></tr></tbody></table>" + } + }, + { + "data": "<table><td></tfoot>A", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,11): unexpected-cell-in-table-body", + "(1,19): XXX-undefined-error", + "(1,20): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "text": "A" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><tbody><tr><td>A</td></tr></tbody></table></body></html>", + "noQuirksBodyHtml": "<table><tbody><tr><td>A</td></tr></tbody></table>" + } + }, + { + "data": "<table><thead><td></tbody>A", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,18): unexpected-cell-in-table-body", + "(1,26): XXX-undefined-error", + "(1,27): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "thead": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "table", + "children": [ + { + "tag": "thead", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "text": "A" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><table><thead><tr><td>A</td></tr></thead></table></body></html>", + "noQuirksBodyHtml": "<table><thead><tr><td>A</td></tr></thead></table>" + } + }, + { + "data": "<legend>test</legend>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "legend": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "legend", + "children": [ + { + "text": "test" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><legend>test</legend></body></html>", + "noQuirksBodyHtml": "<legend>test</legend>" + } + }, + { + "data": "<table><input>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "input": true, + "table": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "input" + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><input><table></table></body></html>", + "noQuirksBodyHtml": "<input><table></table>" + } + }, + { + "data": "<b><em><foo><foo><aside></b>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "em": true, + "foo": true, + "aside": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "em", + "children": [ + { + "tag": "foo", + "children": [ + { + "tag": "foo" + } + ] + } + ] + } + ] + }, + { + "tag": "em", + "children": [ + { + "tag": "aside", + "children": [ + { + "tag": "b" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b><em><foo><foo></foo></foo></em></b><em><aside><b></b></aside></em></body></html>", + "noQuirksBodyHtml": "<b><em><foo><foo></foo></foo></em></b><em><aside><b></b></aside></em>" + } + }, + { + "data": "<b><em><foo><foo><aside></b></em>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "em": true, + "foo": true, + "aside": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "em", + "children": [ + { + "tag": "foo", + "children": [ + { + "tag": "foo" + } + ] + } + ] + } + ] + }, + { + "tag": "em" + }, + { + "tag": "aside", + "children": [ + { + "tag": "em", + "children": [ + { + "tag": "b" + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b><em><foo><foo></foo></foo></em></b><em></em><aside><em><b></b></em></aside></body></html>", + "noQuirksBodyHtml": "<b><em><foo><foo></foo></foo></em></b><em></em><aside><em><b></b></em></aside>" + } + }, + { + "data": "<b><em><foo><foo><foo><aside></b>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "em": true, + "foo": true, + "aside": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "em", + "children": [ + { + "tag": "foo", + "children": [ + { + "tag": "foo", + "children": [ + { + "tag": "foo" + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "aside", + "children": [ + { + "tag": "b" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b><em><foo><foo><foo></foo></foo></foo></em></b><aside><b></b></aside></body></html>", + "noQuirksBodyHtml": "<b><em><foo><foo><foo></foo></foo></foo></em></b><aside><b></b></aside>" + } + }, + { + "data": "<b><em><foo><foo><foo><aside></b></em>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "em": true, + "foo": true, + "aside": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "em", + "children": [ + { + "tag": "foo", + "children": [ + { + "tag": "foo", + "children": [ + { + "tag": "foo" + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "aside", + "children": [ + { + "tag": "b" + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><b><em><foo><foo><foo></foo></foo></foo></em></b><aside><b></b></aside></body></html>", + "noQuirksBodyHtml": "<b><em><foo><foo><foo></foo></foo></foo></em></b><aside><b></b></aside>" + } + }, + { + "data": "<b><em><foo><foo><foo><foo><foo><foo><foo><foo><foo><foo><aside></b></em>", + "errors": [], + "fragment": { + "name": "div" + }, + "document": { + "props": { + "tags": { + "b": true, + "em": true, + "foo": true, + "aside": true + } + }, + "tree": [ + { + "tag": "b", + "children": [ + { + "tag": "em", + "children": [ + { + "tag": "foo", + "children": [ + { + "tag": "foo", + "children": [ + { + "tag": "foo", + "children": [ + { + "tag": "foo", + "children": [ + { + "tag": "foo", + "children": [ + { + "tag": "foo", + "children": [ + { + "tag": "foo", + "children": [ + { + "tag": "foo", + "children": [ + { + "tag": "foo", + "children": [ + { + "tag": "foo" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "aside", + "children": [ + { + "tag": "b" + } + ] + } + ], + "html": "<b><em><foo><foo><foo><foo><foo><foo><foo><foo><foo><foo></foo></foo></foo></foo></foo></foo></foo></foo></foo></foo></em></b><aside><b></b></aside>", + "noQuirksBodyHtml": "<b><em><foo><foo><foo><foo><foo><foo><foo><foo><foo><foo></foo></foo></foo></foo></foo></foo></foo></foo></foo></foo></em></b><aside><b></b></aside>" + } + }, + { + "data": "<b><em><foo><foob><foob><foob><foob><fooc><fooc><fooc><fooc><food><aside></b></em>", + "errors": [], + "fragment": { + "name": "div" + }, + "document": { + "props": { + "tags": { + "b": true, + "em": true, + "foo": true, + "foob": true, + "fooc": true, + "food": true, + "aside": true + } + }, + "tree": [ + { + "tag": "b", + "children": [ + { + "tag": "em", + "children": [ + { + "tag": "foo", + "children": [ + { + "tag": "foob", + "children": [ + { + "tag": "foob", + "children": [ + { + "tag": "foob", + "children": [ + { + "tag": "foob", + "children": [ + { + "tag": "fooc", + "children": [ + { + "tag": "fooc", + "children": [ + { + "tag": "fooc", + "children": [ + { + "tag": "fooc", + "children": [ + { + "tag": "food" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "aside", + "children": [ + { + "tag": "b" + } + ] + } + ], + "html": "<b><em><foo><foob><foob><foob><foob><fooc><fooc><fooc><fooc><food></food></fooc></fooc></fooc></fooc></foob></foob></foob></foob></foo></em></b><aside><b></b></aside>", + "noQuirksBodyHtml": "<b><em><foo><foob><foob><foob><foob><fooc><fooc><fooc><fooc><food></food></fooc></fooc></fooc></fooc></foob></foob></foob></foob></foo></em></b><aside><b></b></aside>" + } + }, + { + "data": "<isindex action=\"x\">", + "errors": [], + "fragment": { + "name": "table" + }, + "document": { + "props": { + "tags": { + "form": true, + "hr": true, + "label": true, + "input": true + } + }, + "tree": [ + { + "tag": "form", + "attrs": [ + { + "name": "action", + "value": "x" + } + ], + "children": [ + { + "tag": "hr" + }, + { + "tag": "label", + "children": [ + { + "text": "This is a searchable index. Enter search keywords: " + }, + { + "tag": "input", + "attrs": [ + { + "name": "name", + "value": "isindex" + } + ] + } + ] + }, + { + "tag": "hr" + } + ] + } + ], + "html": "<form action=\"x\"><hr><label>This is a searchable index. Enter search keywords: <input name=\"isindex\"></label><hr></form>", + "noQuirksBodyHtml": "<form action=\"x\"><hr><label>This is a searchable index. Enter search keywords: <input name=\"isindex\"></label><hr></form>" + } + }, + { + "data": "<option><XH<optgroup></optgroup>", + "errors": [], + "fragment": { + "name": "select" + }, + "document": { + "props": { + "tags": { + "option": true + } + }, + "tree": [ + { + "tag": "option" + } + ], + "html": "<option></option>", + "noQuirksBodyHtml": "<option><xh<optgroup></xh<optgroup></option>" + } + }, + { + "data": "<svg><foreignObject><div>foo</div><plaintext></foreignObject></svg><div>bar</div>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg foreignObject": true, + "div": true, + "plaintext": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "div", + "children": [ + { + "text": "foo" + } + ] + }, + { + "tag": "plaintext", + "children": [ + { + "text": "</foreignObject></svg><div>bar</div>", + "no_escape": true + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg><foreignObject><div>foo</div><plaintext></foreignObject></svg><div>bar</div></plaintext></foreignObject></svg></body></html>", + "noQuirksBodyHtml": "<svg><foreignObject><div>foo</div><plaintext></foreignObject></svg><div>bar</div></plaintext></foreignObject></svg>" + } + }, + { + "data": "<svg><foreignObject></foreignObject><title></svg>foo", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "svg svg": true, + "svg foreignObject": true, + "svg title": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "foreignObject", + "ns": "http://www.w3.org/2000/svg" + }, + { + "tag": "title", + "ns": "http://www.w3.org/2000/svg" + } + ] + }, + { + "text": "foo" + } + ] + } + ] + } + ], + "html": "<html><head></head><body><svg><foreignObject></foreignObject><title></title></svg>foo</body></html>", + "noQuirksBodyHtml": "<svg><foreignObject></foreignObject><title></title></svg>foo" + } + }, + { + "data": "</foreignObject><plaintext><div>foo</div>", + "errors": [], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "plaintext": true + }, + "no_escape": true + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "plaintext", + "children": [ + { + "text": "<div>foo</div>", + "no_escape": true + } + ] + } + ] + } + ] + } + ], + "html": "<html><head></head><body><plaintext><div>foo</div></plaintext></body></html>", + "noQuirksBodyHtml": "<plaintext><div>foo</div></plaintext>" + } + } + ] +} diff --git a/tests/phpunit/includes/title/MediaWikiPageLinkRendererTest.php b/tests/phpunit/includes/title/MediaWikiPageLinkRendererTest.php deleted file mode 100644 index c79471d5e8..0000000000 --- a/tests/phpunit/includes/title/MediaWikiPageLinkRendererTest.php +++ /dev/null @@ -1,168 +0,0 @@ -<?php -/** - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 2 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, write to the Free Software Foundation, Inc., - * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - * http://www.gnu.org/copyleft/gpl.html - * - * @file - * @author Daniel Kinzler - */ - -/** - * @covers MediaWikiPageLinkRenderer - * - * @group Title - * @group Database - */ -class MediaWikiPageLinkRendererTest extends MediaWikiTestCase { - - protected function setUp() { - parent::setUp(); - - $this->setMwGlobals( [ - 'wgContLang' => Language::factory( 'en' ), - ] ); - } - - /** - * Returns a mock GenderCache that will return "female" always. - * - * @return GenderCache - */ - private function getGenderCache() { - $genderCache = $this->getMockBuilder( 'GenderCache' ) - ->disableOriginalConstructor() - ->getMock(); - - $genderCache->expects( $this->any() ) - ->method( 'getGenderOf' ) - ->will( $this->returnValue( 'female' ) ); - - return $genderCache; - } - - public static function provideGetPageUrl() { - return [ - [ - new TitleValue( NS_MAIN, 'Foo_Bar' ), - [], - '/Foo_Bar' - ], - [ - new TitleValue( NS_USER, 'Hansi_Maier', 'stuff' ), - [ 'foo' => 'bar' ], - '/User:Hansi_Maier?foo=bar#stuff' - ], - ]; - } - - /** - * @dataProvider provideGetPageUrl - */ - public function testGetPageUrl( TitleValue $title, $params, $url ) { - // NOTE: was of Feb 2014, MediaWikiPageLinkRenderer *ignores* the - // WikitextTitleFormatter we pass here, and relies on the Linker - // class for generating the link! This may break the test e.g. - // of Linker uses a different language for the namespace names. - - $lang = Language::factory( 'en' ); - - $formatter = new MediaWikiTitleCodec( $lang, $this->getGenderCache() ); - $renderer = new MediaWikiPageLinkRenderer( $formatter, '/' ); - $actual = $renderer->getPageUrl( $title, $params ); - - $this->assertEquals( $url, $actual ); - } - - public static function provideRenderHtmlLink() { - return [ - [ - new TitleValue( NS_MAIN, 'Foo_Bar' ), - 'Foo Bar', - '!<a .*href=".*?Foo_Bar.*?".*?>Foo Bar</a>!' - ], - [ - // NOTE: Linker doesn't include fragments in "broken" links - // NOTE: once this no longer uses Linker, we will get "2" instead of "User" for the namespace. - new TitleValue( NS_USER, 'Hansi_Maier', 'stuff' ), - 'Hansi Maier\'s Stuff', - '!<a .*href=".*?User:Hansi_Maier.*?>Hansi Maier\'s Stuff</a>!' - ], - [ - // NOTE: Linker doesn't include fragments in "broken" links - // NOTE: once this no longer uses Linker, we will get "2" instead of "User" for the namespace. - new TitleValue( NS_USER, 'Hansi_Maier', 'stuff' ), - null, - '!<a .*href=".*?User:Hansi_Maier.*?>User:Hansi Maier#stuff</a>!' - ], - ]; - } - - /** - * @dataProvider provideRenderHtmlLink - */ - public function testRenderHtmlLink( TitleValue $title, $text, $pattern ) { - // NOTE: was of Feb 2014, MediaWikiPageLinkRenderer *ignores* the - // WikitextTitleFormatter we pass here, and relies on the Linker - // class for generating the link! This may break the test e.g. - // of Linker uses a different language for the namespace names. - - $lang = Language::factory( 'en' ); - - $formatter = new MediaWikiTitleCodec( $lang, $this->getGenderCache() ); - $renderer = new MediaWikiPageLinkRenderer( $formatter ); - $actual = $renderer->renderHtmlLink( $title, $text ); - - $this->assertRegExp( $pattern, $actual ); - } - - public static function provideRenderWikitextLink() { - return [ - [ - new TitleValue( NS_MAIN, 'Foo_Bar' ), - 'Foo Bar', - '[[:0:Foo Bar|Foo Bar]]' - ], - [ - new TitleValue( NS_USER, 'Hansi_Maier', 'stuff' ), - 'Hansi Maier\'s Stuff', - '[[:2:Hansi Maier#stuff|Hansi Maier&#39;s Stuff]]' - ], - [ - new TitleValue( NS_USER, 'Hansi_Maier', 'stuff' ), - null, - '[[:2:Hansi Maier#stuff|2:Hansi Maier#stuff]]' - ], - ]; - } - - /** - * @dataProvider provideRenderWikitextLink - */ - public function testRenderWikitextLink( TitleValue $title, $text, $expected ) { - $formatter = $this->getMock( 'TitleFormatter' ); - $formatter->expects( $this->any() ) - ->method( 'getFullText' ) - ->will( $this->returnCallback( - function ( TitleValue $title ) { - return str_replace( '_', ' ', "$title" ); - } - ) ); - - $renderer = new MediaWikiPageLinkRenderer( $formatter, '/' ); - $actual = $renderer->renderWikitextLink( $title, $text ); - - $this->assertEquals( $expected, $actual ); - } -} diff --git a/tests/phpunit/includes/title/MediaWikiTitleCodecTest.php b/tests/phpunit/includes/title/MediaWikiTitleCodecTest.php index e321bdb252..e7ac940b92 100644 --- a/tests/phpunit/includes/title/MediaWikiTitleCodecTest.php +++ b/tests/phpunit/includes/title/MediaWikiTitleCodecTest.php @@ -164,6 +164,7 @@ class MediaWikiTitleCodecTest extends MediaWikiTestCase { // getGenderCache() provides a mock that considers first // names ending in "a" to be female. [ NS_USER, 'Lisa_Müller', '', 'de', 'Benutzerin:Lisa Müller' ], + [ 1000000, 'Invalid_namespace', '', 'en', ':Invalid namespace' ], ]; } @@ -179,6 +180,39 @@ class MediaWikiTitleCodecTest extends MediaWikiTestCase { $this->assertEquals( $expected, $actual ); } + public static function provideGetPrefixedDBkey() { + return [ + [ NS_MAIN, 'Foo_Bar', '', '', 'en', 'Foo_Bar' ], + [ NS_USER, 'Hansi_Maier', 'stuff_and_so_on', '', 'en', 'User:Hansi_Maier' ], + + // No capitalization or normalization is applied while formatting! + [ NS_USER_TALK, 'hansi__maier', '', '', 'en', 'User_talk:hansi__maier' ], + + // getGenderCache() provides a mock that considers first + // names ending in "a" to be female. + [ NS_USER, 'Lisa_Müller', '', '', 'de', 'Benutzerin:Lisa_Müller' ], + + [ NS_MAIN, 'Remote_page', '', 'remotetestiw', 'en', 'remotetestiw:Remote_page' ], + + // non-existent namespace + [ 10000000, 'Foobar', '', '', 'en', ':Foobar' ], + ]; + } + + /** + * @dataProvider provideGetPrefixedDBkey + */ + public function testGetPrefixedDBkey( $namespace, $dbkey, $fragment, + $interwiki, $lang, $expected + ) { + $codec = $this->makeCodec( $lang ); + $title = new TitleValue( $namespace, $dbkey, $fragment, $interwiki ); + + $actual = $codec->getPrefixedDBkey( $title ); + + $this->assertEquals( $expected, $actual ); + } + public static function provideGetFullText() { return [ [ NS_MAIN, 'Foo_Bar', '', 'en', 'Foo Bar' ], @@ -321,9 +355,9 @@ class MediaWikiTitleCodecTest extends MediaWikiTestCase { // XML/HTML character entity references // Note: Commented out because they are not marked invalid by the PHP test as // Title::newFromText runs Sanitizer::decodeCharReferencesAndNormalize first. - // array( 'A &eacute; B' ), - // array( 'A &#233; B' ), - // array( 'A &#x00E9; B' ), + // [ 'A &eacute; B' ], + // [ 'A &#233; B' ], + // [ 'A &#x00E9; B' ], // Subject of NS_TALK does not roundtrip to NS_MAIN [ 'Talk:File:Example.svg' ], // Directory navigation diff --git a/tests/phpunit/includes/title/TitleValueTest.php b/tests/phpunit/includes/title/TitleValueTest.php index 792255339f..4dbda74ae0 100644 --- a/tests/phpunit/includes/title/TitleValueTest.php +++ b/tests/phpunit/includes/title/TitleValueTest.php @@ -42,6 +42,7 @@ class TitleValueTest extends MediaWikiTestCase { $title = new TitleValue( $ns, $text, $fragment, $interwiki ); $this->assertEquals( $ns, $title->getNamespace() ); + $this->assertTrue( $title->inNamespace( $ns ) ); $this->assertEquals( $text, $title->getText() ); $this->assertEquals( $fragment, $title->getFragment() ); $this->assertEquals( $hasFragment, $title->hasFragment() ); diff --git a/tests/phpunit/includes/upload/UploadBaseTest.php b/tests/phpunit/includes/upload/UploadBaseTest.php index ee74957c2c..6be272fb61 100644 --- a/tests/phpunit/includes/upload/UploadBaseTest.php +++ b/tests/phpunit/includes/upload/UploadBaseTest.php @@ -52,7 +52,7 @@ class UploadBaseTest extends MediaWikiTestCase { [ 'ValidTitle.jpg', 'ValidTitle.jpg', UploadBase::OK, 'upload valid title' ], /* A title with a slash */ - [ 'A/B.jpg', 'B.jpg', UploadBase::OK, + [ 'A/B.jpg', 'A-B.jpg', UploadBase::OK, 'upload title with slash' ], /* A title with illegal char */ [ 'A:B.jpg', 'A-B.jpg', UploadBase::OK, @@ -349,7 +349,7 @@ class UploadBaseTest extends MediaWikiTestCase { ], [ // This currently doesn't seem to work in any browsers, but in case - // http://www.w3.org/TR/css3-images/ is implemented for SVG files + // https://www.w3.org/TR/css3-images/ is implemented for SVG files '<svg xmlns="http://www.w3.org/2000/svg"> <rect width="100" height="100" style="background-image:image(\'sprites.svg#xywh=40,0,20,20\')"/> </svg>', true, true, @@ -374,7 +374,12 @@ class UploadBaseTest extends MediaWikiTestCase { false, 'SVG with external entity' ], - + [ + "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"> <g> <a xlink:href=\"javascript:alert('1&#10;https://google.com')\"> <rect width=\"300\" height=\"100\" style=\"fill:rgb(0,0,255);stroke-width:1;stroke:rgb(0,0,2)\" /> </a> </g> </svg>", + true, + true, + 'SVG with javascript <a> link with newline (T122653)' + ], // Test good, but strange files that we want to allow [ '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <g> <a xlink:href="http://en.wikipedia.org/wiki/Main_Page"> <path transform="translate(0,496)" id="path6706" d="m 112.09375,107.6875 -5.0625,3.625 -4.3125,5.03125 -0.46875,0.5 -4.09375,3.34375 -9.125,5.28125 -8.625,-3.375 z" style="fill:#cccccc;fill-opacity:1;stroke:#6e6e6e;stroke-width:0.69999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;display:inline" /> </a> </g> </svg>', @@ -391,6 +396,63 @@ class UploadBaseTest extends MediaWikiTestCase { ]; // @codingStandardsIgnoreEnd } + + /** + * @dataProvider provideDetectScriptInSvg + */ + public function testDetectScriptInSvg( $svg, $expected, $message ) { + // This only checks some weird cases, most tests are in testCheckSvgScriptCallback() above + $result = $this->upload->detectScriptInSvg( $svg, false ); + $this->assertSame( $expected, $result, $message ); + } + + public static function provideDetectScriptInSvg() { + global $IP; + return [ + [ + "$IP/tests/phpunit/data/upload/buggynamespace-original.svg", + false, + 'SVG with a weird but valid namespace definition created by Adobe Illustrator' + ], + [ + "$IP/tests/phpunit/data/upload/buggynamespace-okay.svg", + false, + 'SVG with a namespace definition created by Adobe Illustrator and mangled by Inkscape' + ], + [ + "$IP/tests/phpunit/data/upload/buggynamespace-okay2.svg", + false, + 'SVG with a namespace definition created by Adobe Illustrator and mangled by Inkscape (twice)' + ], + [ + "$IP/tests/phpunit/data/upload/buggynamespace-bad.svg", + [ 'uploadscriptednamespace', 'i' ], + 'SVG with a namespace definition using an undefined entity' + ], + [ + "$IP/tests/phpunit/data/upload/buggynamespace-evilhtml.svg", + [ 'uploadscriptednamespace', 'http://www.w3.org/1999/xhtml' ], + 'SVG with an html namespace encoded as an entity' + ], + ]; + } + + /** + * @dataProvider provideCheckXMLEncodingMissmatch + */ + public function testCheckXMLEncodingMissmatch( $fileContents, $evil ) { + $filename = $this->getNewTempFile(); + file_put_contents( $filename, $fileContents ); + $this->assertSame( UploadBase::checkXMLEncodingMissmatch( $filename ), $evil ); + } + + public function provideCheckXMLEncodingMissmatch() { + return [ + [ '<?xml version="1.0" encoding="utf-7"?><svg></svg>', true ], + [ '<?xml version="1.0" encoding="utf-8"?><svg></svg>', false ], + [ '<?xml version="1.0" encoding="WINDOWS-1252"?><svg></svg>', false ], + ]; + } } class UploadTestHandler extends UploadBase { @@ -420,4 +482,11 @@ class UploadTestHandler extends UploadBase { ); return [ $check->wellFormed, $check->filterMatch ]; } + + /** + * Same as parent function, but override visibility to 'public'. + */ + public function detectScriptInSvg( $filename, $partial ) { + return parent::detectScriptInSvg( $filename, $partial ); + } } diff --git a/tests/phpunit/includes/upload/UploadStashTest.php b/tests/phpunit/includes/upload/UploadStashTest.php index e0b57a797b..9b25505c68 100644 --- a/tests/phpunit/includes/upload/UploadStashTest.php +++ b/tests/phpunit/includes/upload/UploadStashTest.php @@ -7,7 +7,7 @@ */ class UploadStashTest extends MediaWikiTestCase { /** - * @var array Array of UploadStashTestUser + * @var TestUser[] Array of UploadStashTestUser */ public static $users; @@ -55,7 +55,7 @@ class UploadStashTest extends MediaWikiTestCase { * @todo give this test a real name explaining what is being tested here */ public function testBug29408() { - $this->setMwGlobals( 'wgUser', self::$users['uploader']->user ); + $this->setMwGlobals( 'wgUser', self::$users['uploader']->getUser() ); $repo = RepoGroup::singleton()->getLocalRepo(); $stash = new UploadStash( $repo ); diff --git a/tests/phpunit/includes/user/BotPasswordTest.php b/tests/phpunit/includes/user/BotPasswordTest.php index 27ce287e32..81c84e8306 100644 --- a/tests/phpunit/includes/user/BotPasswordTest.php +++ b/tests/phpunit/includes/user/BotPasswordTest.php @@ -1,12 +1,20 @@ <?php use MediaWiki\Session\SessionManager; +use Wikimedia\ScopedCallback; /** * @covers BotPassword * @group Database */ class BotPasswordTest extends MediaWikiTestCase { + + /** @var TestUser */ + private $testUser; + + /** @var string */ + private $testUserName; + protected function setUp() { parent::setUp(); @@ -20,11 +28,14 @@ class BotPasswordTest extends MediaWikiTestCase { 'wgUserrightsInterwikiDelimiter' => '@', ] ); + $this->testUser = $this->getMutableTestUser(); + $this->testUserName = $this->testUser->getUser()->getName(); + $mock1 = $this->getMockForAbstractClass( 'CentralIdLookup' ); $mock1->expects( $this->any() )->method( 'isAttached' ) ->will( $this->returnValue( true ) ); $mock1->expects( $this->any() )->method( 'lookupUserNames' ) - ->will( $this->returnValue( [ 'UTSysop' => 42, 'UTDummy' => 43, 'UTInvalid' => 0 ] ) ); + ->will( $this->returnValue( [ $this->testUserName => 42, 'UTDummy' => 43, 'UTInvalid' => 0 ] ) ); $mock1->expects( $this->never() )->method( 'lookupCentralIds' ); $mock2 = $this->getMockForAbstractClass( 'CentralIdLookup' ); @@ -49,9 +60,7 @@ class BotPasswordTest extends MediaWikiTestCase { public function addDBData() { $passwordFactory = new \PasswordFactory(); $passwordFactory->init( \RequestContext::getMain()->getConfig() ); - // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only - $passwordFactory->setDefaultType( 'A' ); - $pwhash = $passwordFactory->newFromPlaintext( 'foobaz' ); + $passwordHash = $passwordFactory->newFromPlaintext( 'foobaz' ); $dbw = wfGetDB( DB_MASTER ); $dbw->delete( @@ -65,7 +74,7 @@ class BotPasswordTest extends MediaWikiTestCase { [ 'bp_user' => 42, 'bp_app_id' => 'BotPassword', - 'bp_password' => $pwhash->toString(), + 'bp_password' => $passwordHash->toString(), 'bp_token' => 'token!', 'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}', 'bp_grants' => '["test"]', @@ -73,7 +82,7 @@ class BotPasswordTest extends MediaWikiTestCase { [ 'bp_user' => 43, 'bp_app_id' => 'BotPassword', - 'bp_password' => $pwhash->toString(), + 'bp_password' => $passwordHash->toString(), 'bp_token' => 'token!', 'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}', 'bp_grants' => '["test"]', @@ -84,7 +93,7 @@ class BotPasswordTest extends MediaWikiTestCase { } public function testBasics() { - $user = User::newFromName( 'UTSysop' ); + $user = $this->testUser->getUser(); $bp = BotPassword::newFromUser( $user, 'BotPassword' ); $this->assertInstanceOf( 'BotPassword', $bp ); $this->assertTrue( $bp->isSaved() ); @@ -109,7 +118,7 @@ class BotPasswordTest extends MediaWikiTestCase { } public function testUnsaved() { - $user = User::newFromName( 'UTSysop' ); + $user = $this->testUser->getUser(); $bp = BotPassword::newUnsaved( [ 'user' => $user, 'appId' => 'DoesNotExist' @@ -134,7 +143,7 @@ class BotPasswordTest extends MediaWikiTestCase { $this->assertEquals( '{"IPAddresses":["127.0.0.0/8"]}', $bp->getRestrictions()->toJson() ); $this->assertSame( [ 'test' ], $bp->getGrants() ); - $user = User::newFromName( 'UTSysop' ); + $user = $this->testUser->getUser(); $bp = BotPassword::newUnsaved( [ 'centralId' => 45, 'appId' => 'DoesNotExist' @@ -144,7 +153,7 @@ class BotPasswordTest extends MediaWikiTestCase { $this->assertSame( 45, $bp->getUserCentralId() ); $this->assertSame( 'DoesNotExist', $bp->getAppId() ); - $user = User::newFromName( 'UTSysop' ); + $user = $this->testUser->getUser(); $bp = BotPassword::newUnsaved( [ 'user' => $user, 'appId' => 'BotPassword' @@ -161,7 +170,7 @@ class BotPasswordTest extends MediaWikiTestCase { 'appId' => str_repeat( 'X', BotPassword::APPID_MAXLENGTH + 1 ), ] ) ); $this->assertNull( BotPassword::newUnsaved( [ - 'user' => 'UTSysop', + 'user' => $this->testUserName, 'appId' => 'Ok', ] ) ); $this->assertNull( BotPassword::newUnsaved( [ @@ -202,7 +211,7 @@ class BotPasswordTest extends MediaWikiTestCase { $this->assertNotInstanceOf( 'InvalidPassword', $bp1->getPassword(), 'sanity check' ); $this->assertNotInstanceOf( 'InvalidPassword', $bp2->getPassword(), 'sanity check' ); - BotPassword::invalidateAllPasswordsForUser( 'UTSysop' ); + BotPassword::invalidateAllPasswordsForUser( $this->testUserName ); $this->assertInstanceOf( 'InvalidPassword', $bp1->getPassword() ); $this->assertNotInstanceOf( 'InvalidPassword', $bp2->getPassword() ); @@ -214,16 +223,44 @@ class BotPasswordTest extends MediaWikiTestCase { $this->assertNotNull( BotPassword::newFromCentralId( 42, 'BotPassword' ), 'sanity check' ); $this->assertNotNull( BotPassword::newFromCentralId( 43, 'BotPassword' ), 'sanity check' ); - BotPassword::removeAllPasswordsForUser( 'UTSysop' ); + BotPassword::removeAllPasswordsForUser( $this->testUserName ); $this->assertNull( BotPassword::newFromCentralId( 42, 'BotPassword' ) ); $this->assertNotNull( BotPassword::newFromCentralId( 43, 'BotPassword' ) ); } + /** + * @dataProvider provideCanonicalizeLoginData + */ + public function testCanonicalizeLoginData( $username, $password, $expectedResult ) { + $result = BotPassword::canonicalizeLoginData( $username, $password ); + if ( is_array( $expectedResult ) ) { + $this->assertArrayEquals( $expectedResult, $result, true, true ); + } else { + $this->assertSame( $expectedResult, $result ); + } + } + + public function provideCanonicalizeLoginData() { + return [ + [ 'user', 'pass', false ], + [ 'user', 'abc@def', false ], + [ 'legacy@user', 'pass', false ], + [ 'user@bot', '12345678901234567890123456789012', + [ 'user@bot', '12345678901234567890123456789012', true ] ], + [ 'user', 'bot@12345678901234567890123456789012', + [ 'user@bot', '12345678901234567890123456789012', true ] ], + [ 'user', 'bot@12345678901234567890123456789012345', + [ 'user@bot', '12345678901234567890123456789012345', true ] ], + [ 'user', 'bot@x@12345678901234567890123456789012', + [ 'user@bot@x', '12345678901234567890123456789012', true ] ], + ]; + } + public function testLogin() { // Test failure when bot passwords aren't enabled $this->setMwGlobals( 'wgEnableBotPasswords', false ); - $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', new FauxRequest ); + $status = BotPassword::login( "{$this->testUserName}@BotPassword", 'foobaz', new FauxRequest ); $this->assertEquals( Status::newFatal( 'botpasswords-disabled' ), $status ); $this->setMwGlobals( 'wgEnableBotPasswords', true ); @@ -237,7 +274,7 @@ class BotPasswordTest extends MediaWikiTestCase { $manager->getProvider( MediaWiki\Session\BotPasswordSessionProvider::class ), 'sanity check' ); - $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', new FauxRequest ); + $status = BotPassword::login( "{$this->testUserName}@BotPassword", 'foobaz', new FauxRequest ); $this->assertEquals( Status::newFatal( 'botpasswords-no-provider' ), $status ); ScopedCallback::consume( $reset ); @@ -259,7 +296,7 @@ class BotPasswordTest extends MediaWikiTestCase { $reset = MediaWiki\Session\TestUtils::setSessionManagerSingleton( $manager ); // No "@"-thing in the username - $status = BotPassword::login( 'UTSysop', 'foobaz', new FauxRequest ); + $status = BotPassword::login( $this->testUserName, 'foobaz', new FauxRequest ); $this->assertEquals( Status::newFatal( 'botpasswords-invalid-name', '@' ), $status ); // No base user @@ -267,9 +304,9 @@ class BotPasswordTest extends MediaWikiTestCase { $this->assertEquals( Status::newFatal( 'nosuchuser', 'UTDummy' ), $status ); // No bot password - $status = BotPassword::login( 'UTSysop@DoesNotExist', 'foobaz', new FauxRequest ); + $status = BotPassword::login( "{$this->testUserName}@DoesNotExist", 'foobaz', new FauxRequest ); $this->assertEquals( - Status::newFatal( 'botpasswords-not-exist', 'UTSysop', 'DoesNotExist' ), + Status::newFatal( 'botpasswords-not-exist', $this->testUserName, 'DoesNotExist' ), $status ); @@ -277,11 +314,12 @@ class BotPasswordTest extends MediaWikiTestCase { $request = $this->getMock( 'FauxRequest', [ 'getIP' ] ); $request->expects( $this->any() )->method( 'getIP' ) ->will( $this->returnValue( '10.0.0.1' ) ); - $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', $request ); + $status = BotPassword::login( "{$this->testUserName}@BotPassword", 'foobaz', $request ); $this->assertEquals( Status::newFatal( 'botpasswords-restriction-failed' ), $status ); // Wrong password - $status = BotPassword::login( 'UTSysop@BotPassword', 'UTSysopPassword', new FauxRequest ); + $status = BotPassword::login( + "{$this->testUserName}@BotPassword", $this->testUser->getPassword(), new FauxRequest ); $this->assertEquals( Status::newFatal( 'wrongpassword' ), $status ); // Success! @@ -291,7 +329,7 @@ class BotPasswordTest extends MediaWikiTestCase { $request->getSession()->getProvider(), 'sanity check' ); - $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', $request ); + $status = BotPassword::login( "{$this->testUserName}@BotPassword", 'foobaz', $request ); $this->assertInstanceOf( 'Status', $status ); $this->assertTrue( $status->isGood() ); $session = $status->getValue(); @@ -311,8 +349,6 @@ class BotPasswordTest extends MediaWikiTestCase { public function testSave( $password ) { $passwordFactory = new \PasswordFactory(); $passwordFactory->init( \RequestContext::getMain()->getConfig() ); - // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only - $passwordFactory->setDefaultType( 'A' ); $bp = BotPassword::newUnsaved( [ 'centralId' => 42, @@ -325,9 +361,9 @@ class BotPasswordTest extends MediaWikiTestCase { BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST ), 'sanity check' ); - $pwhash = $password ? $passwordFactory->newFromPlaintext( $password ) : null; - $this->assertFalse( $bp->save( 'update', $pwhash ) ); - $this->assertTrue( $bp->save( 'insert', $pwhash ) ); + $passwordHash = $password ? $passwordFactory->newFromPlaintext( $password ) : null; + $this->assertFalse( $bp->save( 'update', $passwordHash ) ); + $this->assertTrue( $bp->save( 'insert', $passwordHash ) ); $bp2 = BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST ); $this->assertInstanceOf( 'BotPassword', $bp2 ); $this->assertEquals( $bp->getUserCentralId(), $bp2->getUserCentralId() ); @@ -356,9 +392,9 @@ class BotPasswordTest extends MediaWikiTestCase { $this->assertTrue( $pw->equals( $password ) ); } - $pwhash = $passwordFactory->newFromPlaintext( 'XXX' ); + $passwordHash = $passwordFactory->newFromPlaintext( 'XXX' ); $token = $bp->getToken(); - $this->assertTrue( $bp->save( 'update', $pwhash ) ); + $this->assertTrue( $bp->save( 'update', $passwordHash ) ); $this->assertNotEquals( $token, $bp->getToken() ); $pw = TestingAccessWrapper::newFromObject( $bp )->getPassword(); $this->assertTrue( $pw->equals( 'XXX' ) ); diff --git a/tests/phpunit/includes/user/CentralIdLookupTest.php b/tests/phpunit/includes/user/CentralIdLookupTest.php index 1786261c35..feac641eb3 100644 --- a/tests/phpunit/includes/user/CentralIdLookupTest.php +++ b/tests/phpunit/includes/user/CentralIdLookupTest.php @@ -45,7 +45,7 @@ class CentralIdLookupTest extends MediaWikiTestCase { $this->getMockForAbstractClass( 'CentralIdLookup' ) ); - $user = User::newFromName( 'UTSysop' ); + $user = static::getTestSysop()->getUser(); $this->assertSame( $user, $mock->checkAudience( $user ) ); $user = $mock->checkAudience( CentralIdLookup::AUDIENCE_PUBLIC ); diff --git a/tests/phpunit/includes/user/LocalIdLookupTest.php b/tests/phpunit/includes/user/LocalIdLookupTest.php index c86fb6cff3..c91d8e0cb5 100644 --- a/tests/phpunit/includes/user/LocalIdLookupTest.php +++ b/tests/phpunit/includes/user/LocalIdLookupTest.php @@ -18,18 +18,14 @@ class LocalIdLookupTest extends MediaWikiTestCase { public function addDBData() { for ( $i = 1; $i <= 4; $i++ ) { - $user = User::newFromName( "UTLocalIdLookup$i" ); - if ( $user->getId() == 0 ) { - $user->addToDatabase(); - } - $this->localUsers["UTLocalIdLookup$i"] = $user->getId(); + $this->localUsers[] = $this->getMutableTestUser()->getUser(); } - User::newFromName( 'UTLocalIdLookup1' )->addGroup( 'local-id-lookup-test' ); + $sysop = static::getTestSysop()->getUser(); $block = new Block( [ - 'address' => 'UTLocalIdLookup3', - 'by' => User::idFromName( 'UTSysop' ), + 'address' => $this->localUsers[2]->getName(), + 'by' => $sysop->getId(), 'reason' => __METHOD__, 'expiry' => '1 day', 'hideName' => false, @@ -37,8 +33,8 @@ class LocalIdLookupTest extends MediaWikiTestCase { $block->insert(); $block = new Block( [ - 'address' => 'UTLocalIdLookup4', - 'by' => User::idFromName( 'UTSysop' ), + 'address' => $this->localUsers[3]->getName(), + 'by' => $sysop->getId(), 'reason' => __METHOD__, 'expiry' => '1 day', 'hideName' => true, @@ -46,9 +42,14 @@ class LocalIdLookupTest extends MediaWikiTestCase { $block->insert(); } + public function getLookupUser() { + return static::getTestUser( [ 'local-id-lookup-test' ] )->getUser(); + } + public function testLookupCentralIds() { $lookup = new LocalIdLookup(); - $user1 = User::newFromName( 'UTLocalIdLookup1' ); + + $user1 = $this->getLookupUser(); $user2 = User::newFromName( 'UTLocalIdLookup2' ); $this->assertTrue( $user1->isAllowed( 'hideuser' ), 'sanity check' ); @@ -56,12 +57,15 @@ class LocalIdLookupTest extends MediaWikiTestCase { $this->assertSame( [], $lookup->lookupCentralIds( [] ) ); - $expect = array_flip( $this->localUsers ); - $expect[123] = 'X'; + $expect = []; + foreach ( $this->localUsers as $localUser ) { + $expect[$localUser->getId()] = $localUser->getName(); + } + $expect[12345] = 'X'; ksort( $expect ); $expect2 = $expect; - $expect2[$this->localUsers['UTLocalIdLookup4']] = ''; + $expect2[$this->localUsers[3]->getId()] = ''; $arg = array_fill_keys( array_keys( $expect ), 'X' ); @@ -73,7 +77,7 @@ class LocalIdLookupTest extends MediaWikiTestCase { public function testLookupUserNames() { $lookup = new LocalIdLookup(); - $user1 = User::newFromName( 'UTLocalIdLookup1' ); + $user1 = $this->getLookupUser(); $user2 = User::newFromName( 'UTLocalIdLookup2' ); $this->assertTrue( $user1->isAllowed( 'hideuser' ), 'sanity check' ); @@ -81,12 +85,15 @@ class LocalIdLookupTest extends MediaWikiTestCase { $this->assertSame( [], $lookup->lookupUserNames( [] ) ); - $expect = $this->localUsers; + $expect = []; + foreach ( $this->localUsers as $localUser ) { + $expect[$localUser->getName()] = $localUser->getId(); + } $expect['UTDoesNotExist'] = 'X'; ksort( $expect ); $expect2 = $expect; - $expect2['UTLocalIdLookup4'] = 'X'; + $expect2[$this->localUsers[3]->getName()] = 'X'; $arg = array_fill_keys( array_keys( $expect ), 'X' ); @@ -98,7 +105,7 @@ class LocalIdLookupTest extends MediaWikiTestCase { public function testIsAttached() { $lookup = new LocalIdLookup(); - $user1 = User::newFromName( 'UTLocalIdLookup1' ); + $user1 = $this->getLookupUser(); $user2 = User::newFromName( 'DoesNotExist' ); $this->assertTrue( $lookup->isAttached( $user1 ) ); @@ -130,7 +137,7 @@ class LocalIdLookupTest extends MediaWikiTestCase { $lookup = new LocalIdLookup(); $this->assertSame( $sharedDB && $sharedTable && $localDBSet, - $lookup->isAttached( User::newFromName( 'UTLocalIdLookup1' ), 'shared' ) + $lookup->isAttached( $this->getLookupUser(), 'shared' ) ); } diff --git a/tests/phpunit/includes/user/PasswordResetTest.php b/tests/phpunit/includes/user/PasswordResetTest.php new file mode 100644 index 0000000000..7ff882a5ad --- /dev/null +++ b/tests/phpunit/includes/user/PasswordResetTest.php @@ -0,0 +1,152 @@ +<?php + +use MediaWiki\Auth\AuthManager; + +/** + * @group Database + */ +class PasswordResetTest extends PHPUnit_Framework_TestCase { + /** + * @dataProvider provideIsAllowed + */ + public function testIsAllowed( $passwordResetRoutes, $enableEmail, + $allowsAuthenticationDataChange, $canEditPrivate, $canSeePassword, + $userIsBlocked, $isAllowed + ) { + $config = new HashConfig( [ + 'PasswordResetRoutes' => $passwordResetRoutes, + 'EnableEmail' => $enableEmail, + ] ); + + $authManager = $this->getMockBuilder( AuthManager::class )->disableOriginalConstructor() + ->getMock(); + $authManager->expects( $this->any() )->method( 'allowsAuthenticationDataChange' ) + ->willReturn( $allowsAuthenticationDataChange ? Status::newGood() : Status::newFatal( 'foo' ) ); + + $user = $this->getMock( User::class ); + $user->expects( $this->any() )->method( 'getName' )->willReturn( 'Foo' ); + $user->expects( $this->any() )->method( 'isBlocked' )->willReturn( $userIsBlocked ); + $user->expects( $this->any() )->method( 'isAllowed' ) + ->will( $this->returnCallback( function ( $perm ) use ( $canEditPrivate, $canSeePassword ) { + if ( $perm === 'editmyprivateinfo' ) { + return $canEditPrivate; + } elseif ( $perm === 'passwordreset' ) { + return $canSeePassword; + } else { + $this->fail( 'Unexpected permission check' ); + } + } ) ); + + $passwordReset = new PasswordReset( $config, $authManager ); + + $this->assertSame( $isAllowed, $passwordReset->isAllowed( $user )->isGood() ); + } + + public function provideIsAllowed() { + return [ + [ + 'passwordResetRoutes' => [], + 'enableEmail' => true, + 'allowsAuthenticationDataChange' => true, + 'canEditPrivate' => true, + 'canSeePassword' => true, + 'userIsBlocked' => false, + 'isAllowed' => false, + ], + [ + 'passwordResetRoutes' => [ 'username' => true ], + 'enableEmail' => false, + 'allowsAuthenticationDataChange' => true, + 'canEditPrivate' => true, + 'canSeePassword' => true, + 'userIsBlocked' => false, + 'isAllowed' => false, + ], + [ + 'passwordResetRoutes' => [ 'username' => true ], + 'enableEmail' => true, + 'allowsAuthenticationDataChange' => false, + 'canEditPrivate' => true, + 'canSeePassword' => true, + 'userIsBlocked' => false, + 'isAllowed' => false, + ], + [ + 'passwordResetRoutes' => [ 'username' => true ], + 'enableEmail' => true, + 'allowsAuthenticationDataChange' => true, + 'canEditPrivate' => false, + 'canSeePassword' => true, + 'userIsBlocked' => false, + 'isAllowed' => false, + ], + [ + 'passwordResetRoutes' => [ 'username' => true ], + 'enableEmail' => true, + 'allowsAuthenticationDataChange' => true, + 'canEditPrivate' => true, + 'canSeePassword' => true, + 'userIsBlocked' => true, + 'isAllowed' => false, + ], + [ + 'passwordResetRoutes' => [ 'username' => true ], + 'enableEmail' => true, + 'allowsAuthenticationDataChange' => true, + 'canEditPrivate' => true, + 'canSeePassword' => false, + 'userIsBlocked' => false, + 'isAllowed' => true, + ], + [ + 'passwordResetRoutes' => [ 'username' => true ], + 'enableEmail' => true, + 'allowsAuthenticationDataChange' => true, + 'canEditPrivate' => true, + 'canSeePassword' => true, + 'userIsBlocked' => false, + 'isAllowed' => true, + ], + ]; + } + + public function testExecute_email() { + $config = new HashConfig( [ + 'PasswordResetRoutes' => [ 'username' => true, 'email' => true ], + 'EnableEmail' => true, + ] ); + + $authManager = $this->getMockBuilder( AuthManager::class )->disableOriginalConstructor() + ->getMock(); + $authManager->expects( $this->any() )->method( 'allowsAuthenticationDataChange' ) + ->willReturn( Status::newGood() ); + $authManager->expects( $this->exactly( 2 ) )->method( 'changeAuthenticationData' ); + + $request = new FauxRequest(); + $request->setIP( '1.2.3.4' ); + $performingUser = $this->getMock( User::class ); + $performingUser->expects( $this->any() )->method( 'getRequest' )->willReturn( $request ); + $performingUser->expects( $this->any() )->method( 'isAllowed' )->willReturn( true ); + + $targetUser1 = $this->getMock( User::class ); + $targetUser2 = $this->getMock( User::class ); + $targetUser1->expects( $this->any() )->method( 'getName' )->willReturn( 'User1' ); + $targetUser2->expects( $this->any() )->method( 'getName' )->willReturn( 'User2' ); + $targetUser1->expects( $this->any() )->method( 'getId' )->willReturn( 1 ); + $targetUser2->expects( $this->any() )->method( 'getId' )->willReturn( 2 ); + $targetUser1->expects( $this->any() )->method( 'getEmail' )->willReturn( 'foo@bar.baz' ); + $targetUser2->expects( $this->any() )->method( 'getEmail' )->willReturn( 'foo@bar.baz' ); + + $passwordReset = $this->getMockBuilder( PasswordReset::class ) + ->setMethods( [ 'getUsersByEmail' ] )->setConstructorArgs( [ $config, $authManager ] ) + ->getMock(); + $passwordReset->expects( $this->any() )->method( 'getUsersByEmail' )->with( 'foo@bar.baz' ) + ->willReturn( [ $targetUser1, $targetUser2 ] ); + + $status = $passwordReset->isAllowed( $performingUser ); + $this->assertTrue( $status->isGood() ); + + $status = $passwordReset->execute( $performingUser, null, 'foo@bar.baz' ); + $this->assertTrue( $status->isGood() ); + } +} diff --git a/tests/phpunit/includes/user/UserTest.php b/tests/phpunit/includes/user/UserTest.php index c9b6929e7a..0819bf255c 100644 --- a/tests/phpunit/includes/user/UserTest.php +++ b/tests/phpunit/includes/user/UserTest.php @@ -3,6 +3,8 @@ define( 'NS_UNITTEST', 5600 ); define( 'NS_UNITTEST_TALK', 5601 ); +use MediaWiki\MediaWikiServices; + /** * @group Database */ @@ -92,6 +94,57 @@ class UserTest extends MediaWikiTestCase { $this->assertNotContains( 'nukeworld', $rights ); } + /** + * @covers User::getRights + */ + public function testUserGetRightsHooks() { + $user = new User; + $user->addGroup( 'unittesters' ); + $user->addGroup( 'testwriters' ); + $userWrapper = TestingAccessWrapper::newFromObject( $user ); + + $rights = $user->getRights(); + $this->assertContains( 'test', $rights, 'sanity check' ); + $this->assertContains( 'runtest', $rights, 'sanity check' ); + $this->assertContains( 'writetest', $rights, 'sanity check' ); + $this->assertNotContains( 'nukeworld', $rights, 'sanity check' ); + + // Add a hook manipluating the rights + $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'UserGetRights' => [ function ( $user, &$rights ) { + $rights[] = 'nukeworld'; + $rights = array_diff( $rights, [ 'writetest' ] ); + } ] ] ); + + $userWrapper->mRights = null; + $rights = $user->getRights(); + $this->assertContains( 'test', $rights ); + $this->assertContains( 'runtest', $rights ); + $this->assertNotContains( 'writetest', $rights ); + $this->assertContains( 'nukeworld', $rights ); + + // Add a Session that limits rights + $mock = $this->getMockBuilder( stdclass::class ) + ->setMethods( [ 'getAllowedUserRights', 'deregisterSession', 'getSessionId' ] ) + ->getMock(); + $mock->method( 'getAllowedUserRights' )->willReturn( [ 'test', 'writetest' ] ); + $mock->method( 'getSessionId' )->willReturn( + new MediaWiki\Session\SessionId( str_repeat( 'X', 32 ) ) + ); + $session = MediaWiki\Session\TestUtils::getDummySession( $mock ); + $mockRequest = $this->getMockBuilder( FauxRequest::class ) + ->setMethods( [ 'getSession' ] ) + ->getMock(); + $mockRequest->method( 'getSession' )->willReturn( $session ); + $userWrapper->mRequest = $mockRequest; + + $userWrapper->mRights = null; + $rights = $user->getRights(); + $this->assertContains( 'test', $rights ); + $this->assertNotContains( 'runtest', $rights ); + $this->assertNotContains( 'writetest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + } + /** * @dataProvider provideGetGroupsWithPermission * @covers User::getGroupsWithPermission @@ -212,30 +265,30 @@ class UserTest extends MediaWikiTestCase { * @group medium * @covers User::getEditCount */ - public function testEditCount() { - $user = User::newFromName( 'UnitTestUser' ); - - if ( !$user->getId() ) { - $user->addToDatabase(); - } + public function testGetEditCount() { + $user = $this->getMutableTestUser()->getUser(); // let the user have a few (3) edits $page = WikiPage::factory( Title::newFromText( 'Help:UserTest_EditCount' ) ); for ( $i = 0; $i < 3; $i++ ) { - $page->doEdit( (string)$i, 'test', 0, false, $user ); + $page->doEditContent( + ContentHandler::makeContent( (string)$i, $page->getTitle() ), + 'test', + 0, + false, + $user + ); } - $user->clearInstanceCache(); $this->assertEquals( 3, $user->getEditCount(), 'After three edits, the user edit count should be 3' ); - // increase the edit count and clear the cache + // increase the edit count $user->incEditCount(); - $user->clearInstanceCache(); $this->assertEquals( 4, $user->getEditCount(), @@ -243,23 +296,65 @@ class UserTest extends MediaWikiTestCase { ); } + /** + * Test User::editCount + * @group medium + * @covers User::getEditCount + */ + public function testGetEditCountForAnons() { + $user = User::newFromName( 'Anonymous' ); + + $this->assertNull( + $user->getEditCount(), + 'Edit count starts null for anonymous users.' + ); + + $user->incEditCount(); + + $this->assertNull( + $user->getEditCount(), + 'Edit count remains null for anonymous users despite calls to increase it.' + ); + } + + /** + * Test User::editCount + * @group medium + * @covers User::incEditCount + */ + public function testIncEditCount() { + $user = $this->getMutableTestUser()->getUser(); + $user->incEditCount(); + + $reloadedUser = User::newFromId( $user->getId() ); + $reloadedUser->incEditCount(); + + $this->assertEquals( + 2, + $reloadedUser->getEditCount(), + 'Increasing the edit count after a fresh load leaves the object up to date.' + ); + } + /** * Test changing user options. * @covers User::setOption * @covers User::getOption */ public function testOptions() { - $user = User::newFromName( 'UnitTestUser' ); - - if ( !$user->getId() ) { - $user->addToDatabase(); - } + $user = $this->getMutableTestUser()->getUser(); $user->setOption( 'userjs-someoption', 'test' ); $user->setOption( 'cols', 200 ); $user->saveSettings(); - $user = User::newFromName( 'UnitTestUser' ); + $user = User::newFromName( $user->getName() ); + $user->load( User::READ_LATEST ); + $this->assertEquals( 'test', $user->getOption( 'userjs-someoption' ) ); + $this->assertEquals( 200, $user->getOption( 'cols' ) ); + + $user = User::newFromName( $user->getName() ); + MediaWikiServices::getInstance()->getMainWANObjectCache()->clearProcessCache(); $this->assertEquals( 'test', $user->getOption( 'userjs-someoption' ) ); $this->assertEquals( 200, $user->getOption( 'cols' ) ); } @@ -298,7 +393,7 @@ class UserTest extends MediaWikiTestCase { 'MinimalPasswordLength' => 6, 'PasswordCannotMatchUsername' => true, 'PasswordCannotMatchBlacklist' => true, - 'MaximalPasswordLength' => 30, + 'MaximalPasswordLength' => 40, ], ], 'checks' => [ @@ -311,7 +406,8 @@ class UserTest extends MediaWikiTestCase { ], ] ); - $user = User::newFromName( 'Useruser' ); + $user = static::getTestUser()->getUser(); + // Sanity $this->assertTrue( $user->isValidPassword( 'Password1234' ) ); @@ -322,18 +418,19 @@ class UserTest extends MediaWikiTestCase { $this->assertEquals( 'passwordtooshort', $user->getPasswordValidity( 'a' ) ); // Maximum length - $longPass = str_repeat( 'a', 31 ); + $longPass = str_repeat( 'a', 41 ); $this->assertFalse( $user->isValidPassword( $longPass ) ); $this->assertFalse( $user->checkPasswordValidity( $longPass )->isGood() ); $this->assertFalse( $user->checkPasswordValidity( $longPass )->isOK() ); $this->assertEquals( 'passwordtoolong', $user->getPasswordValidity( $longPass ) ); // Matches username - $this->assertFalse( $user->checkPasswordValidity( 'Useruser' )->isGood() ); - $this->assertTrue( $user->checkPasswordValidity( 'Useruser' )->isOK() ); - $this->assertEquals( 'password-name-match', $user->getPasswordValidity( 'Useruser' ) ); + $this->assertFalse( $user->checkPasswordValidity( $user->getName() )->isGood() ); + $this->assertTrue( $user->checkPasswordValidity( $user->getName() )->isOK() ); + $this->assertEquals( 'password-name-match', $user->getPasswordValidity( $user->getName() ) ); // On the forbidden list + $user = User::newFromName( 'Useruser' ); $this->assertFalse( $user->checkPasswordValidity( 'Passpass' )->isGood() ); $this->assertEquals( 'password-login-forbidden', $user->getPasswordValidity( 'Passpass' ) ); } @@ -389,29 +486,23 @@ class UserTest extends MediaWikiTestCase { * @covers User::equals */ public function testEquals() { - $first = User::newFromName( 'EqualUser' ); - $second = User::newFromName( 'EqualUser' ); + $first = $this->getMutableTestUser()->getUser(); + $second = User::newFromName( $first->getName() ); $this->assertTrue( $first->equals( $first ) ); $this->assertTrue( $first->equals( $second ) ); $this->assertTrue( $second->equals( $first ) ); - $third = User::newFromName( '0' ); - $fourth = User::newFromName( '000' ); + $third = $this->getMutableTestUser()->getUser(); + $fourth = $this->getMutableTestUser()->getUser(); $this->assertFalse( $third->equals( $fourth ) ); $this->assertFalse( $fourth->equals( $third ) ); // Test users loaded from db with id - $user = User::newFromName( 'EqualUnitTestUser' ); - if ( !$user->getId() ) { - $user->addToDatabase(); - } - - $id = $user->getId(); - - $fifth = User::newFromId( $id ); - $sixth = User::newFromName( 'EqualUnitTestUser' ); + $user = $this->getMutableTestUser()->getUser(); + $fifth = User::newFromId( $user->getId() ); + $sixth = User::newFromName( $user->getName() ); $this->assertTrue( $fifth->equals( $sixth ) ); } @@ -419,9 +510,8 @@ class UserTest extends MediaWikiTestCase { * @covers User::getId */ public function testGetId() { - $user = User::newFromName( 'UTSysop' ); + $user = static::getTestUser()->getUser(); $this->assertTrue( $user->getId() > 0 ); - } /** @@ -429,7 +519,7 @@ class UserTest extends MediaWikiTestCase { * @covers User::isAnon */ public function testLoggedIn() { - $user = User::newFromName( 'UTSysop' ); + $user = $this->getMutableTestUser()->getUser(); $this->assertTrue( $user->isLoggedIn() ); $this->assertFalse( $user->isAnon() ); @@ -447,7 +537,8 @@ class UserTest extends MediaWikiTestCase { * @covers User::checkAndSetTouched */ public function testCheckAndSetTouched() { - $user = TestingAccessWrapper::newFromObject( User::newFromName( 'UTSysop' ) ); + $user = $this->getMutableTestUser()->getUser(); + $user = TestingAccessWrapper::newFromObject( $user ); $this->assertTrue( $user->isLoggedIn() ); $touched = $user->getDBTouched(); @@ -462,4 +553,195 @@ class UserTest extends MediaWikiTestCase { $this->assertGreaterThan( $touched, $user->getDBTouched(), "user_touched increased with casOnTouched() #2" ); } + + /** + * @covers User::findUsersByGroup + */ + public function testFindUsersByGroup() { + $users = User::findUsersByGroup( [] ); + $this->assertEquals( 0, iterator_count( $users ) ); + + $users = User::findUsersByGroup( 'foo' ); + $this->assertEquals( 0, iterator_count( $users ) ); + + $user = $this->getMutableTestUser( [ 'foo' ] )->getUser(); + $users = User::findUsersByGroup( 'foo' ); + $this->assertEquals( 1, iterator_count( $users ) ); + $users->rewind(); + $this->assertTrue( $user->equals( $users->current() ) ); + + // arguments have OR relationship + $user2 = $this->getMutableTestUser( [ 'bar' ] )->getUser(); + $users = User::findUsersByGroup( [ 'foo', 'bar' ] ); + $this->assertEquals( 2, iterator_count( $users ) ); + $users->rewind(); + $this->assertTrue( $user->equals( $users->current() ) ); + $users->next(); + $this->assertTrue( $user2->equals( $users->current() ) ); + + // users are not duplicated + $user = $this->getMutableTestUser( [ 'baz', 'boom' ] )->getUser(); + $users = User::findUsersByGroup( [ 'baz', 'boom' ] ); + $this->assertEquals( 1, iterator_count( $users ) ); + $users->rewind(); + $this->assertTrue( $user->equals( $users->current() ) ); + } + + /** + * When a user is autoblocked a cookie is set with which to track them + * in case they log out and change IP addresses. + * @link https://phabricator.wikimedia.org/T5233 + */ + public function testAutoblockCookies() { + // Set up the bits of global configuration that we use. + $this->setMwGlobals( [ + 'wgCookieSetOnAutoblock' => true, + 'wgCookiePrefix' => 'wmsitetitle', + ] ); + + // 1. Log in a test user, and block them. + $user1tmp = $this->getTestUser()->getUser(); + $request1 = new FauxRequest(); + $request1->getSession()->setUser( $user1tmp ); + $expiryFiveDays = time() + ( 5 * 24 * 60 * 60 ); + $block = new Block( [ + 'enableAutoblock' => true, + 'expiry' => wfTimestamp( TS_MW, $expiryFiveDays ), + ] ); + $block->setTarget( $user1tmp ); + $block->insert(); + $user1 = User::newFromSession( $request1 ); + $user1->mBlock = $block; + $user1->load(); + + // Confirm that the block has been applied as required. + $this->assertTrue( $user1->isLoggedIn() ); + $this->assertTrue( $user1->isBlocked() ); + $this->assertEquals( Block::TYPE_USER, $block->getType() ); + $this->assertTrue( $block->isAutoblocking() ); + $this->assertGreaterThanOrEqual( 1, $block->getId() ); + + // Test for the desired cookie name, value, and expiry. + $cookies = $request1->response()->getCookies(); + $this->assertArrayHasKey( 'wmsitetitleBlockID', $cookies ); + $this->assertEquals( $block->getId(), $cookies['wmsitetitleBlockID']['value'] ); + $this->assertEquals( $expiryFiveDays, $cookies['wmsitetitleBlockID']['expire'] ); + + // 2. Create a new request, set the cookies, and see if the (anon) user is blocked. + $request2 = new FauxRequest(); + $request2->setCookie( 'BlockID', $block->getId() ); + $user2 = User::newFromSession( $request2 ); + $user2->load(); + $this->assertNotEquals( $user1->getId(), $user2->getId() ); + $this->assertNotEquals( $user1->getToken(), $user2->getToken() ); + $this->assertTrue( $user2->isAnon() ); + $this->assertFalse( $user2->isLoggedIn() ); + $this->assertTrue( $user2->isBlocked() ); + $this->assertEquals( true, $user2->getBlock()->isAutoblocking() ); // Non-strict type-check. + // Can't directly compare the objects becuase of member type differences. + // One day this will work: $this->assertEquals( $block, $user2->getBlock() ); + $this->assertEquals( $block->getId(), $user2->getBlock()->getId() ); + $this->assertEquals( $block->getExpiry(), $user2->getBlock()->getExpiry() ); + + // 3. Finally, set up a request as a new user, and the block should still be applied. + $user3tmp = $this->getTestUser()->getUser(); + $request3 = new FauxRequest(); + $request3->getSession()->setUser( $user3tmp ); + $request3->setCookie( 'BlockID', $block->getId() ); + $user3 = User::newFromSession( $request3 ); + $user3->load(); + $this->assertTrue( $user3->isLoggedIn() ); + $this->assertTrue( $user3->isBlocked() ); + $this->assertEquals( true, $user3->getBlock()->isAutoblocking() ); // Non-strict type-check. + + // Clean up. + $block->delete(); + } + + /** + * Make sure that no cookie is set to track autoblocked users + * when $wgCookieSetOnAutoblock is false. + */ + public function testAutoblockCookiesDisabled() { + // Set up the bits of global configuration that we use. + $this->setMwGlobals( [ + 'wgCookieSetOnAutoblock' => false, + 'wgCookiePrefix' => 'wm_no_cookies', + ] ); + + // 1. Log in a test user, and block them. + $testUser = $this->getTestUser()->getUser(); + $request1 = new FauxRequest(); + $request1->getSession()->setUser( $testUser ); + $block = new Block( [ 'enableAutoblock' => true ] ); + $block->setTarget( $testUser ); + $block->insert(); + $user = User::newFromSession( $request1 ); + $user->mBlock = $block; + $user->load(); + + // 2. Test that the cookie IS NOT present. + $this->assertTrue( $user->isLoggedIn() ); + $this->assertTrue( $user->isBlocked() ); + $this->assertEquals( Block::TYPE_USER, $block->getType() ); + $this->assertTrue( $block->isAutoblocking() ); + $this->assertGreaterThanOrEqual( 1, $user->getBlockId() ); + $this->assertGreaterThanOrEqual( $block->getId(), $user->getBlockId() ); + $cookies = $request1->response()->getCookies(); + $this->assertArrayNotHasKey( 'wm_no_cookiesBlockID', $cookies ); + + // Clean up. + $block->delete(); + } + + /** + * When a user is autoblocked and a cookie is set to track them, the expiry time of the cookie + * should match the block's expiry. If the block is infinite, the cookie expiry time should + * match $wgCookieExpiration. If the expiry time is changed, the cookie's should change with it. + */ + public function testAutoblockCookieInfiniteExpiry() { + $cookieExpiration = 20 * 24 * 60 * 60; // 20 days + $this->setMwGlobals( [ + 'wgCookieSetOnAutoblock' => true, + 'wgCookieExpiration' => $cookieExpiration, + 'wgCookiePrefix' => 'wm_infinite_block', + ] ); + // 1. Log in a test user, and block them indefinitely. + $user1Tmp = $this->getTestUser()->getUser(); + $request1 = new FauxRequest(); + $request1->getSession()->setUser( $user1Tmp ); + $block = new Block( [ 'enableAutoblock' => true, 'expiry' => 'infinity' ] ); + $block->setTarget( $user1Tmp ); + $block->insert(); + $user1 = User::newFromSession( $request1 ); + $user1->mBlock = $block; + $user1->load(); + + // 2. Test the cookie's expiry timestamp. + $this->assertTrue( $user1->isLoggedIn() ); + $this->assertTrue( $user1->isBlocked() ); + $this->assertEquals( Block::TYPE_USER, $block->getType() ); + $this->assertTrue( $block->isAutoblocking() ); + $this->assertGreaterThanOrEqual( 1, $user1->getBlockId() ); + $cookies = $request1->response()->getCookies(); + // Calculate the expected cookie expiry date. + $this->assertArrayHasKey( 'wm_infinite_blockBlockID', $cookies ); + $this->assertEquals( time() + $cookieExpiration, $cookies['wm_infinite_blockBlockID']['expire'] ); + + // 3. Change the block's expiry (to 2 days), and the cookie's should be changed also. + $newExpiry = time() + 2 * 24 * 60 * 60; + $block->mExpiry = wfTimestamp( TS_MW, $newExpiry ); + $block->update(); + $user2tmp = $this->getTestUser()->getUser(); + $request2 = new FauxRequest(); + $request2->getSession()->setUser( $user2tmp ); + $user2 = User::newFromSession( $request2 ); + $user2->mBlock = $block; + $user2->load(); + $cookies = $request2->response()->getCookies(); + $this->assertEquals( $newExpiry, $cookies['wm_infinite_blockBlockID']['expire'] ); + + // Clean up. + $block->delete(); + } } diff --git a/tests/phpunit/includes/utils/BatchRowUpdateTest.php b/tests/phpunit/includes/utils/BatchRowUpdateTest.php index 560b6d2fb6..cb1b3d2af6 100644 --- a/tests/phpunit/includes/utils/BatchRowUpdateTest.php +++ b/tests/phpunit/includes/utils/BatchRowUpdateTest.php @@ -81,7 +81,7 @@ class BatchRowUpdateTest extends MediaWikiTestCase { */ public function testReaderGetPrimaryKey( $message, array $expected, array $row ) { $reader = new BatchRowIterator( $this->mockDb(), 'some_table', array_keys( $expected ), 8675309 ); - $this->assertEquals( $expected, $reader->extractPrimaryKeys( (object) $row ), $message ); + $this->assertEquals( $expected, $reader->extractPrimaryKeys( (object)$row ), $message ); } public static function provider_readerSetFetchColumns() { @@ -129,7 +129,7 @@ class BatchRowUpdateTest extends MediaWikiTestCase { $db = $this->mockDb(); $db->expects( $this->once() ) ->method( 'select' ) - // only testing second parameter of DatabaseBase::select + // only testing second parameter of Database::select ->with( 'some_table', $columns ) ->will( $this->returnValue( new ArrayIterator( [] ) ) ); @@ -164,7 +164,7 @@ class BatchRowUpdateTest extends MediaWikiTestCase { /** * Slightly hackish to use reflection, but asserting different parameters - * to consecutive calls of DatabaseBase::select in phpunit is error prone + * to consecutive calls of Database::select in phpunit is error prone * * @dataProvider provider_readerSelectConditions */ @@ -214,7 +214,7 @@ class BatchRowUpdateTest extends MediaWikiTestCase { protected function consecutivelyReturnFromSelect( array $results ) { $retvals = []; foreach ( $results as $rows ) { - // The DatabaseBase::select method returns iterators, so we do too. + // The Database::select method returns iterators, so we do too. $retvals[] = $this->returnValue( new ArrayIterator( $rows ) ); } @@ -226,7 +226,7 @@ class BatchRowUpdateTest extends MediaWikiTestCase { for ( $i = 0; $i < $numRows; $i += $batchSize ) { $rows = []; for ( $j = 0; $j < $batchSize && $i + $j < $numRows; $j++ ) { - $rows [] = (object) call_user_func( $rowGenerator ); + $rows [] = (object)call_user_func( $rowGenerator ); } $res[] = $rows; } @@ -235,8 +235,7 @@ class BatchRowUpdateTest extends MediaWikiTestCase { } protected function mockDb() { - // Cant mock from DatabaseType or DatabaseBase, they dont - // have the full gamut of methods + // @TODO: mock from Database // FIXME: the constructor normally sets mAtomicLevels and mSrvCache $databaseMysql = $this->getMockBuilder( 'DatabaseMysql' ) ->disableOriginalConstructor() diff --git a/tests/phpunit/includes/utils/FileContentsHasherTest.php b/tests/phpunit/includes/utils/FileContentsHasherTest.php index 2de4bffbe6..0ee4c1342d 100644 --- a/tests/phpunit/includes/utils/FileContentsHasherTest.php +++ b/tests/phpunit/includes/utils/FileContentsHasherTest.php @@ -3,7 +3,7 @@ /** * @covers FileContentsHasherTest */ -class FileContentsHasherTest extends MediaWikiTestCase { +class FileContentsHasherTest extends PHPUnit_Framework_TestCase { public function provideSingleFile() { return array_map( function ( $file ) { diff --git a/tests/phpunit/includes/utils/IPTest.php b/tests/phpunit/includes/utils/IPTest.php deleted file mode 100644 index 5e0626b6bb..0000000000 --- a/tests/phpunit/includes/utils/IPTest.php +++ /dev/null @@ -1,670 +0,0 @@ -<?php -/** - * Tests for IP validity functions. - * - * Ported from /t/inc/IP.t by avar. - * - * @group IP - * @todo Test methods in this call should be split into a method and a - * dataprovider. - */ - -class IPTest extends PHPUnit_Framework_TestCase { - /** - * @covers IP::isIPAddress - * @dataProvider provideInvalidIPs - */ - public function isNotIPAddress( $val, $desc ) { - $this->assertFalse( IP::isIPAddress( $val ), $desc ); - } - - /** - * Provide a list of things that aren't IP addresses - */ - public function provideInvalidIPs() { - return [ - [ false, 'Boolean false is not an IP' ], - [ true, 'Boolean true is not an IP' ], - [ '', 'Empty string is not an IP' ], - [ 'abc', 'Garbage IP string' ], - [ ':', 'Single ":" is not an IP' ], - [ '2001:0DB8::A:1::1', 'IPv6 with a double :: occurrence' ], - [ '2001:0DB8::A:1::', 'IPv6 with a double :: occurrence, last at end' ], - [ '::2001:0DB8::5:1', 'IPv6 with a double :: occurrence, firt at beginning' ], - [ '124.24.52', 'IPv4 not enough quads' ], - [ '24.324.52.13', 'IPv4 out of range' ], - [ '.24.52.13', 'IPv4 starts with period' ], - [ 'fc:100:300', 'IPv6 with only 3 words' ], - ]; - } - - /** - * @covers IP::isIPAddress - */ - public function testisIPAddress() { - $this->assertTrue( IP::isIPAddress( '::' ), 'RFC 4291 IPv6 Unspecified Address' ); - $this->assertTrue( IP::isIPAddress( '::1' ), 'RFC 4291 IPv6 Loopback Address' ); - $this->assertTrue( IP::isIPAddress( '74.24.52.13/20', 'IPv4 range' ) ); - $this->assertTrue( IP::isIPAddress( 'fc:100:a:d:1:e:ac:0/24' ), 'IPv6 range' ); - $this->assertTrue( IP::isIPAddress( 'fc::100:a:d:1:e:ac/96' ), 'IPv6 range with "::"' ); - - $validIPs = [ 'fc:100::', 'fc:100:a:d:1:e:ac::', 'fc::100', '::fc:100:a:d:1:e:ac', - '::fc', 'fc::100:a:d:1:e:ac', 'fc:100:a:d:1:e:ac:0', '124.24.52.13', '1.24.52.13' ]; - foreach ( $validIPs as $ip ) { - $this->assertTrue( IP::isIPAddress( $ip ), "$ip is a valid IP address" ); - } - } - - /** - * @covers IP::isIPv6 - */ - public function testisIPv6() { - $this->assertFalse( IP::isIPv6( ':fc:100::' ), 'IPv6 starting with lone ":"' ); - $this->assertFalse( IP::isIPv6( 'fc:100:::' ), 'IPv6 ending with a ":::"' ); - $this->assertFalse( IP::isIPv6( 'fc:300' ), 'IPv6 with only 2 words' ); - $this->assertFalse( IP::isIPv6( 'fc:100:300' ), 'IPv6 with only 3 words' ); - - $this->assertTrue( IP::isIPv6( 'fc:100::' ) ); - $this->assertTrue( IP::isIPv6( 'fc:100:a::' ) ); - $this->assertTrue( IP::isIPv6( 'fc:100:a:d::' ) ); - $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1::' ) ); - $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1:e::' ) ); - $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1:e:ac::' ) ); - - $this->assertFalse( IP::isIPv6( 'fc:100:a:d:1:e:ac:0::' ), 'IPv6 with 8 words ending with "::"' ); - $this->assertFalse( - IP::isIPv6( 'fc:100:a:d:1:e:ac:0:1::' ), - 'IPv6 with 9 words ending with "::"' - ); - - $this->assertFalse( IP::isIPv6( ':::' ) ); - $this->assertFalse( IP::isIPv6( '::0:' ), 'IPv6 ending in a lone ":"' ); - - $this->assertTrue( IP::isIPv6( '::' ), 'IPv6 zero address' ); - $this->assertTrue( IP::isIPv6( '::0' ) ); - $this->assertTrue( IP::isIPv6( '::fc' ) ); - $this->assertTrue( IP::isIPv6( '::fc:100' ) ); - $this->assertTrue( IP::isIPv6( '::fc:100:a' ) ); - $this->assertTrue( IP::isIPv6( '::fc:100:a:d' ) ); - $this->assertTrue( IP::isIPv6( '::fc:100:a:d:1' ) ); - $this->assertTrue( IP::isIPv6( '::fc:100:a:d:1:e' ) ); - $this->assertTrue( IP::isIPv6( '::fc:100:a:d:1:e:ac' ) ); - - $this->assertFalse( IP::isIPv6( '::fc:100:a:d:1:e:ac:0' ), 'IPv6 with "::" and 8 words' ); - $this->assertFalse( IP::isIPv6( '::fc:100:a:d:1:e:ac:0:1' ), 'IPv6 with 9 words' ); - - $this->assertFalse( IP::isIPv6( ':fc::100' ), 'IPv6 starting with lone ":"' ); - $this->assertFalse( IP::isIPv6( 'fc::100:' ), 'IPv6 ending with lone ":"' ); - $this->assertFalse( IP::isIPv6( 'fc:::100' ), 'IPv6 with ":::" in the middle' ); - - $this->assertTrue( IP::isIPv6( 'fc::100' ), 'IPv6 with "::" and 2 words' ); - $this->assertTrue( IP::isIPv6( 'fc::100:a' ), 'IPv6 with "::" and 3 words' ); - $this->assertTrue( IP::isIPv6( 'fc::100:a:d', 'IPv6 with "::" and 4 words' ) ); - $this->assertTrue( IP::isIPv6( 'fc::100:a:d:1' ), 'IPv6 with "::" and 5 words' ); - $this->assertTrue( IP::isIPv6( 'fc::100:a:d:1:e' ), 'IPv6 with "::" and 6 words' ); - $this->assertTrue( IP::isIPv6( 'fc::100:a:d:1:e:ac' ), 'IPv6 with "::" and 7 words' ); - $this->assertTrue( IP::isIPv6( '2001::df' ), 'IPv6 with "::" and 2 words' ); - $this->assertTrue( IP::isIPv6( '2001:5c0:1400:a::df' ), 'IPv6 with "::" and 5 words' ); - $this->assertTrue( IP::isIPv6( '2001:5c0:1400:a::df:2' ), 'IPv6 with "::" and 6 words' ); - - $this->assertFalse( IP::isIPv6( 'fc::100:a:d:1:e:ac:0' ), 'IPv6 with "::" and 8 words' ); - $this->assertFalse( IP::isIPv6( 'fc::100:a:d:1:e:ac:0:1' ), 'IPv6 with 9 words' ); - - $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1:e:ac:0' ) ); - } - - /** - * @covers IP::isIPv4 - * @dataProvider provideInvalidIPv4Addresses - */ - public function testisNotIPv4( $bogusIP, $desc ) { - $this->assertFalse( IP::isIPv4( $bogusIP ), $desc ); - } - - public function provideInvalidIPv4Addresses() { - return [ - [ false, 'Boolean false is not an IP' ], - [ true, 'Boolean true is not an IP' ], - [ '', 'Empty string is not an IP' ], - [ 'abc', 'Letters are not an IP' ], - [ ':', 'A colon is not an IP' ], - [ '124.24.52', 'IPv4 not enough quads' ], - [ '24.324.52.13', 'IPv4 out of range' ], - [ '.24.52.13', 'IPv4 starts with period' ], - ]; - } - - /** - * @covers IP::isIPv4 - * @dataProvider provideValidIPv4Address - */ - public function testIsIPv4( $ip, $desc ) { - $this->assertTrue( IP::isIPv4( $ip ), $desc ); - } - - /** - * Provide some IPv4 addresses and ranges - */ - public function provideValidIPv4Address() { - return [ - [ '124.24.52.13', 'Valid IPv4 address' ], - [ '1.24.52.13', 'Another valid IPv4 address' ], - [ '74.24.52.13/20', 'An IPv4 range' ], - ]; - } - - /** - * @covers IP::isValid - */ - public function testValidIPs() { - foreach ( range( 0, 255 ) as $i ) { - $a = sprintf( "%03d", $i ); - $b = sprintf( "%02d", $i ); - $c = sprintf( "%01d", $i ); - foreach ( array_unique( [ $a, $b, $c ] ) as $f ) { - $ip = "$f.$f.$f.$f"; - $this->assertTrue( IP::isValid( $ip ), "$ip is a valid IPv4 address" ); - } - } - foreach ( range( 0x0, 0xFFFF, 0xF ) as $i ) { - $a = sprintf( "%04x", $i ); - $b = sprintf( "%03x", $i ); - $c = sprintf( "%02x", $i ); - foreach ( array_unique( [ $a, $b, $c ] ) as $f ) { - $ip = "$f:$f:$f:$f:$f:$f:$f:$f"; - $this->assertTrue( IP::isValid( $ip ), "$ip is a valid IPv6 address" ); - } - } - // test with some abbreviations - $this->assertFalse( IP::isValid( ':fc:100::' ), 'IPv6 starting with lone ":"' ); - $this->assertFalse( IP::isValid( 'fc:100:::' ), 'IPv6 ending with a ":::"' ); - $this->assertFalse( IP::isValid( 'fc:300' ), 'IPv6 with only 2 words' ); - $this->assertFalse( IP::isValid( 'fc:100:300' ), 'IPv6 with only 3 words' ); - - $this->assertTrue( IP::isValid( 'fc:100::' ) ); - $this->assertTrue( IP::isValid( 'fc:100:a:d:1:e::' ) ); - $this->assertTrue( IP::isValid( 'fc:100:a:d:1:e:ac::' ) ); - - $this->assertTrue( IP::isValid( 'fc::100' ), 'IPv6 with "::" and 2 words' ); - $this->assertTrue( IP::isValid( 'fc::100:a' ), 'IPv6 with "::" and 3 words' ); - $this->assertTrue( IP::isValid( '2001::df' ), 'IPv6 with "::" and 2 words' ); - $this->assertTrue( IP::isValid( '2001:5c0:1400:a::df' ), 'IPv6 with "::" and 5 words' ); - $this->assertTrue( IP::isValid( '2001:5c0:1400:a::df:2' ), 'IPv6 with "::" and 6 words' ); - $this->assertTrue( IP::isValid( 'fc::100:a:d:1' ), 'IPv6 with "::" and 5 words' ); - $this->assertTrue( IP::isValid( 'fc::100:a:d:1:e:ac' ), 'IPv6 with "::" and 7 words' ); - - $this->assertFalse( - IP::isValid( 'fc:100:a:d:1:e:ac:0::' ), - 'IPv6 with 8 words ending with "::"' - ); - $this->assertFalse( - IP::isValid( 'fc:100:a:d:1:e:ac:0:1::' ), - 'IPv6 with 9 words ending with "::"' - ); - } - - /** - * @covers IP::isValid - */ - public function testInvalidIPs() { - // Out of range... - foreach ( range( 256, 999 ) as $i ) { - $a = sprintf( "%03d", $i ); - $b = sprintf( "%02d", $i ); - $c = sprintf( "%01d", $i ); - foreach ( array_unique( [ $a, $b, $c ] ) as $f ) { - $ip = "$f.$f.$f.$f"; - $this->assertFalse( IP::isValid( $ip ), "$ip is not a valid IPv4 address" ); - } - } - foreach ( range( 'g', 'z' ) as $i ) { - $a = sprintf( "%04s", $i ); - $b = sprintf( "%03s", $i ); - $c = sprintf( "%02s", $i ); - foreach ( array_unique( [ $a, $b, $c ] ) as $f ) { - $ip = "$f:$f:$f:$f:$f:$f:$f:$f"; - $this->assertFalse( IP::isValid( $ip ), "$ip is not a valid IPv6 address" ); - } - } - // Have CIDR - $ipCIDRs = [ - '212.35.31.121/32', - '212.35.31.121/18', - '212.35.31.121/24', - '::ff:d:321:5/96', - 'ff::d3:321:5/116', - 'c:ff:12:1:ea:d:321:5/120', - ]; - foreach ( $ipCIDRs as $i ) { - $this->assertFalse( IP::isValid( $i ), - "$i is an invalid IP address because it is a block" ); - } - // Incomplete/garbage - $invalid = [ - 'www.xn--var-xla.net', - '216.17.184.G', - '216.17.184.1.', - '216.17.184', - '216.17.184.', - '256.17.184.1' - ]; - foreach ( $invalid as $i ) { - $this->assertFalse( IP::isValid( $i ), "$i is an invalid IP address" ); - } - } - - /** - * Provide some valid IP blocks - */ - public function provideValidBlocks() { - return [ - [ '116.17.184.5/32' ], - [ '0.17.184.5/30' ], - [ '16.17.184.1/24' ], - [ '30.242.52.14/1' ], - [ '10.232.52.13/8' ], - [ '30.242.52.14/0' ], - [ '::e:f:2001/96' ], - [ '::c:f:2001/128' ], - [ '::10:f:2001/70' ], - [ '::fe:f:2001/1' ], - [ '::6d:f:2001/8' ], - [ '::fe:f:2001/0' ], - ]; - } - - /** - * @covers IP::isValidBlock - * @dataProvider provideValidBlocks - */ - public function testValidBlocks( $block ) { - $this->assertTrue( IP::isValidBlock( $block ), "$block is a valid IP block" ); - } - - /** - * @covers IP::isValidBlock - * @dataProvider provideInvalidBlocks - */ - public function testInvalidBlocks( $invalid ) { - $this->assertFalse( IP::isValidBlock( $invalid ), "$invalid is not a valid IP block" ); - } - - public function provideInvalidBlocks() { - return [ - [ '116.17.184.5/33' ], - [ '0.17.184.5/130' ], - [ '16.17.184.1/-1' ], - [ '10.232.52.13/*' ], - [ '7.232.52.13/ab' ], - [ '11.232.52.13/' ], - [ '::e:f:2001/129' ], - [ '::c:f:2001/228' ], - [ '::10:f:2001/-1' ], - [ '::6d:f:2001/*' ], - [ '::86:f:2001/ab' ], - [ '::23:f:2001/' ], - ]; - } - - /** - * @covers IP::sanitizeIP - * @dataProvider provideSanitizeIP - */ - public function testSanitizeIP( $expected, $input ) { - $result = IP::sanitizeIP( $input ); - $this->assertEquals( $expected, $result ); - } - - /** - * Provider for IP::testSanitizeIP() - */ - public static function provideSanitizeIP() { - return [ - [ '0.0.0.0', '0.0.0.0' ], - [ '0.0.0.0', '00.00.00.00' ], - [ '0.0.0.0', '000.000.000.000' ], - [ '141.0.11.253', '141.000.011.253' ], - [ '1.2.4.5', '1.2.4.5' ], - [ '1.2.4.5', '01.02.04.05' ], - [ '1.2.4.5', '001.002.004.005' ], - [ '10.0.0.1', '010.0.000.1' ], - [ '80.72.250.4', '080.072.250.04' ], - [ 'Foo.1000.00', 'Foo.1000.00' ], - [ 'Bar.01', 'Bar.01' ], - [ 'Bar.010', 'Bar.010' ], - [ null, '' ], - [ null, ' ' ] - ]; - } - - /** - * @covers IP::toHex - * @dataProvider provideToHex - */ - public function testToHex( $expected, $input ) { - $result = IP::toHex( $input ); - $this->assertTrue( $result === false || is_string( $result ) ); - $this->assertEquals( $expected, $result ); - } - - /** - * Provider for IP::testToHex() - */ - public static function provideToHex() { - return [ - [ '00000001', '0.0.0.1' ], - [ '01020304', '1.2.3.4' ], - [ '7F000001', '127.0.0.1' ], - [ '80000000', '128.0.0.0' ], - [ 'DEADCAFE', '222.173.202.254' ], - [ 'FFFFFFFF', '255.255.255.255' ], - [ '8D000BFD', '141.000.11.253' ], - [ false, 'IN.VA.LI.D' ], - [ 'v6-00000000000000000000000000000001', '::1' ], - [ 'v6-20010DB885A3000000008A2E03707334', '2001:0db8:85a3:0000:0000:8a2e:0370:7334' ], - [ 'v6-20010DB885A3000000008A2E03707334', '2001:db8:85a3::8a2e:0370:7334' ], - [ false, 'IN:VA::LI:D' ], - [ false, ':::1' ] - ]; - } - - /** - * @covers IP::isPublic - * @dataProvider provideIsPublic - */ - public function testIsPublic( $expected, $input ) { - $result = IP::isPublic( $input ); - $this->assertEquals( $expected, $result ); - } - - /** - * Provider for IP::testIsPublic() - */ - public static function provideIsPublic() { - return [ - [ false, 'fc00::3' ], # RFC 4193 (local) - [ false, 'fc00::ff' ], # RFC 4193 (local) - [ false, '127.1.2.3' ], # loopback - [ false, '::1' ], # loopback - [ false, 'fe80::1' ], # link-local - [ false, '169.254.1.1' ], # link-local - [ false, '10.0.0.1' ], # RFC 1918 (private) - [ false, '172.16.0.1' ], # RFC 1918 (private) - [ false, '192.168.0.1' ], # RFC 1918 (private) - [ true, '2001:5c0:1000:a::133' ], # public - [ true, 'fc::3' ], # public - [ true, '00FC::' ] # public - ]; - } - - // Private wrapper used to test CIDR Parsing. - private function assertFalseCIDR( $CIDR, $msg = '' ) { - $ff = [ false, false ]; - $this->assertEquals( $ff, IP::parseCIDR( $CIDR ), $msg ); - } - - // Private wrapper to test network shifting using only dot notation - private function assertNet( $expected, $CIDR ) { - $parse = IP::parseCIDR( $CIDR ); - $this->assertEquals( $expected, long2ip( $parse[0] ), "network shifting $CIDR" ); - } - - /** - * @covers IP::hexToQuad - * @dataProvider provideIPsAndHexes - */ - public function testHexToQuad( $ip, $hex ) { - $this->assertEquals( $ip, IP::hexToQuad( $hex ) ); - } - - /** - * Provide some IP addresses and their equivalent hex representations - */ - public function provideIPsandHexes() { - return [ - [ '0.0.0.1', '00000001' ], - [ '255.0.0.0', 'FF000000' ], - [ '255.255.255.255', 'FFFFFFFF' ], - [ '10.188.222.255', '0ABCDEFF' ], - // hex not left-padded... - [ '0.0.0.0', '0' ], - [ '0.0.0.1', '1' ], - [ '0.0.0.255', 'FF' ], - [ '0.0.255.0', 'FF00' ], - ]; - } - - /** - * @covers IP::hexToOctet - * @dataProvider provideOctetsAndHexes - */ - public function testHexToOctet( $octet, $hex ) { - $this->assertEquals( $octet, IP::hexToOctet( $hex ) ); - } - - /** - * Provide some hex and octet representations of the same IPs - */ - public function provideOctetsAndHexes() { - return [ - [ '0:0:0:0:0:0:0:1', '00000000000000000000000000000001' ], - [ '0:0:0:0:0:0:FF:3', '00000000000000000000000000FF0003' ], - [ '0:0:0:0:0:0:FF00:6', '000000000000000000000000FF000006' ], - [ '0:0:0:0:0:0:FCCF:FAFF', '000000000000000000000000FCCFFAFF' ], - [ 'FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF', 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' ], - // hex not left-padded... - [ '0:0:0:0:0:0:0:0', '0' ], - [ '0:0:0:0:0:0:0:1', '1' ], - [ '0:0:0:0:0:0:0:FF', 'FF' ], - [ '0:0:0:0:0:0:0:FFD0', 'FFD0' ], - [ '0:0:0:0:0:0:FA00:0', 'FA000000' ], - [ '0:0:0:0:0:0:FCCF:FAFF', 'FCCFFAFF' ], - ]; - } - - /** - * IP::parseCIDR() returns an array containing a signed IP address - * representing the network mask and the bit mask. - * @covers IP::parseCIDR - */ - public function testCIDRParsing() { - $this->assertFalseCIDR( '192.0.2.0', "missing mask" ); - $this->assertFalseCIDR( '192.0.2.0/', "missing bitmask" ); - - // Verify if statement - $this->assertFalseCIDR( '256.0.0.0/32', "invalid net" ); - $this->assertFalseCIDR( '192.0.2.0/AA', "mask not numeric" ); - $this->assertFalseCIDR( '192.0.2.0/-1', "mask < 0" ); - $this->assertFalseCIDR( '192.0.2.0/33', "mask > 32" ); - - // Check internal logic - # 0 mask always result in array(0,0) - $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '192.0.0.2/0' ) ); - $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '0.0.0.0/0' ) ); - $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '255.255.255.255/0' ) ); - - // @todo FIXME: Add more tests. - - # This part test network shifting - $this->assertNet( '192.0.0.0', '192.0.0.2/24' ); - $this->assertNet( '192.168.5.0', '192.168.5.13/24' ); - $this->assertNet( '10.0.0.160', '10.0.0.161/28' ); - $this->assertNet( '10.0.0.0', '10.0.0.3/28' ); - $this->assertNet( '10.0.0.0', '10.0.0.3/30' ); - $this->assertNet( '10.0.0.4', '10.0.0.4/30' ); - $this->assertNet( '172.17.32.0', '172.17.35.48/21' ); - $this->assertNet( '10.128.0.0', '10.135.0.0/9' ); - $this->assertNet( '134.0.0.0', '134.0.5.1/8' ); - } - - /** - * @covers IP::canonicalize - */ - public function testIPCanonicalizeOnValidIp() { - $this->assertEquals( '192.0.2.152', IP::canonicalize( '192.0.2.152' ), - 'Canonicalization of a valid IP returns it unchanged' ); - } - - /** - * @covers IP::canonicalize - */ - public function testIPCanonicalizeMappedAddress() { - $this->assertEquals( - '192.0.2.152', - IP::canonicalize( '::ffff:192.0.2.152' ) - ); - $this->assertEquals( - '192.0.2.152', - IP::canonicalize( '::192.0.2.152' ) - ); - } - - /** - * Issues there are most probably from IP::toHex() or IP::parseRange() - * @covers IP::isInRange - * @dataProvider provideIPsAndRanges - */ - public function testIPIsInRange( $expected, $addr, $range, $message = '' ) { - $this->assertEquals( - $expected, - IP::isInRange( $addr, $range ), - $message - ); - } - - /** Provider for testIPIsInRange() */ - public static function provideIPsAndRanges() { - # Format: (expected boolean, address, range, optional message) - return [ - # IPv4 - [ true, '192.0.2.0', '192.0.2.0/24', 'Network address' ], - [ true, '192.0.2.77', '192.0.2.0/24', 'Simple address' ], - [ true, '192.0.2.255', '192.0.2.0/24', 'Broadcast address' ], - - [ false, '0.0.0.0', '192.0.2.0/24' ], - [ false, '255.255.255', '192.0.2.0/24' ], - - # IPv6 - [ false, '::1', '2001:DB8::/32' ], - [ false, '::', '2001:DB8::/32' ], - [ false, 'FE80::1', '2001:DB8::/32' ], - - [ true, '2001:DB8::', '2001:DB8::/32' ], - [ true, '2001:0DB8::', '2001:DB8::/32' ], - [ true, '2001:DB8::1', '2001:DB8::/32' ], - [ true, '2001:0DB8::1', '2001:DB8::/32' ], - [ true, '2001:0DB8:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF', - '2001:DB8::/32' ], - - [ false, '2001:0DB8:F::', '2001:DB8::/96' ], - ]; - } - - /** - * Test for IP::splitHostAndPort(). - * @dataProvider provideSplitHostAndPort - */ - public function testSplitHostAndPort( $expected, $input, $description ) { - $this->assertEquals( $expected, IP::splitHostAndPort( $input ), $description ); - } - - /** - * Provider for IP::splitHostAndPort() - */ - public static function provideSplitHostAndPort() { - return [ - [ false, '[', 'Unclosed square bracket' ], - [ false, '[::', 'Unclosed square bracket 2' ], - [ [ '::', false ], '::', 'Bare IPv6 0' ], - [ [ '::1', false ], '::1', 'Bare IPv6 1' ], - [ [ '::', false ], '[::]', 'Bracketed IPv6 0' ], - [ [ '::1', false ], '[::1]', 'Bracketed IPv6 1' ], - [ [ '::1', 80 ], '[::1]:80', 'Bracketed IPv6 with port' ], - [ false, '::x', 'Double colon but no IPv6' ], - [ [ 'x', 80 ], 'x:80', 'Hostname and port' ], - [ false, 'x:x', 'Hostname and invalid port' ], - [ [ 'x', false ], 'x', 'Plain hostname' ] - ]; - } - - /** - * Test for IP::combineHostAndPort() - * @dataProvider provideCombineHostAndPort - */ - public function testCombineHostAndPort( $expected, $input, $description ) { - list( $host, $port, $defaultPort ) = $input; - $this->assertEquals( - $expected, - IP::combineHostAndPort( $host, $port, $defaultPort ), - $description ); - } - - /** - * Provider for IP::combineHostAndPort() - */ - public static function provideCombineHostAndPort() { - return [ - [ '[::1]', [ '::1', 2, 2 ], 'IPv6 default port' ], - [ '[::1]:2', [ '::1', 2, 3 ], 'IPv6 non-default port' ], - [ 'x', [ 'x', 2, 2 ], 'Normal default port' ], - [ 'x:2', [ 'x', 2, 3 ], 'Normal non-default port' ], - ]; - } - - /** - * Test for IP::sanitizeRange() - * @dataProvider provideIPCIDRs - */ - public function testSanitizeRange( $input, $expected, $description ) { - $this->assertEquals( $expected, IP::sanitizeRange( $input ), $description ); - } - - /** - * Provider for IP::testSanitizeRange() - */ - public static function provideIPCIDRs() { - return [ - [ '35.56.31.252/16', '35.56.0.0/16', 'IPv4 range' ], - [ '135.16.21.252/24', '135.16.21.0/24', 'IPv4 range' ], - [ '5.36.71.252/32', '5.36.71.252/32', 'IPv4 silly range' ], - [ '5.36.71.252', '5.36.71.252', 'IPv4 non-range' ], - [ '0:1:2:3:4:c5:f6:7/96', '0:1:2:3:4:C5:0:0/96', 'IPv6 range' ], - [ '0:1:2:3:4:5:6:7/120', '0:1:2:3:4:5:6:0/120', 'IPv6 range' ], - [ '0:e1:2:3:4:5:e6:7/128', '0:E1:2:3:4:5:E6:7/128', 'IPv6 silly range' ], - [ '0:c1:A2:3:4:5:c6:7', '0:C1:A2:3:4:5:C6:7', 'IPv6 non range' ], - ]; - } - - /** - * Test for IP::prettifyIP() - * @dataProvider provideIPsToPrettify - */ - public function testPrettifyIP( $ip, $prettified ) { - $this->assertEquals( $prettified, IP::prettifyIP( $ip ), "Prettify of $ip" ); - } - - /** - * Provider for IP::testPrettifyIP() - */ - public static function provideIPsToPrettify() { - return [ - [ '0:0:0:0:0:0:0:0', '::' ], - [ '0:0:0::0:0:0', '::' ], - [ '0:0:0:1:0:0:0:0', '0:0:0:1::' ], - [ '0:0::f', '::f' ], - [ '0::0:0:0:33:fef:b', '::33:fef:b' ], - [ '3f:535:0:0:0:0:e:fbb', '3f:535::e:fbb' ], - [ '0:0:fef:0:0:0:e:fbb', '0:0:fef::e:fbb' ], - [ 'abbc:2004::0:0:0:0', 'abbc:2004::' ], - [ 'cebc:2004:f:0:0:0:0:0', 'cebc:2004:f::' ], - [ '0:0:0:0:0:0:0:0/16', '::/16' ], - [ '0:0:0::0:0:0/64', '::/64' ], - [ '0:0::f/52', '::f/52' ], - [ '::0:0:33:fef:b/52', '::33:fef:b/52' ], - [ '3f:535:0:0:0:0:e:fbb/48', '3f:535::e:fbb/48' ], - [ '0:0:fef:0:0:0:e:fbb/96', '0:0:fef::e:fbb/96' ], - [ 'abbc:2004:0:0::0:0/40', 'abbc:2004::/40' ], - [ 'aebc:2004:f:0:0:0:0:0/80', 'aebc:2004:f::/80' ], - ]; - } -} diff --git a/tests/phpunit/includes/utils/MWCryptHKDFTest.php b/tests/phpunit/includes/utils/MWCryptHKDFTest.php index fafd4fa18e..760d41e86c 100644 --- a/tests/phpunit/includes/utils/MWCryptHKDFTest.php +++ b/tests/phpunit/includes/utils/MWCryptHKDFTest.php @@ -37,7 +37,7 @@ class MWCryptHKDFTest extends MediaWikiTestCase { } /** - * Test vectors from Appendix A on http://tools.ietf.org/html/rfc5869 + * Test vectors from Appendix A on https://tools.ietf.org/html/rfc5869 */ public static function providerRfc5869() { diff --git a/tests/phpunit/includes/utils/MWCryptHashTest.php b/tests/phpunit/includes/utils/MWCryptHashTest.php index 4c85c3dc79..905d14ca09 100644 --- a/tests/phpunit/includes/utils/MWCryptHashTest.php +++ b/tests/phpunit/includes/utils/MWCryptHashTest.php @@ -4,7 +4,7 @@ * @group Hash */ -class MWCryptHashTest extends MediaWikiTestCase { +class MWCryptHashTest extends PHPUnit_Framework_TestCase { public function testHashLength() { if ( MWCryptHash::hashAlgo() !== 'whirlpool' ) { diff --git a/tests/phpunit/languages/LanguageTest.php b/tests/phpunit/languages/LanguageTest.php index 0cf2d0f546..e2e64920ef 100644 --- a/tests/phpunit/languages/LanguageTest.php +++ b/tests/phpunit/languages/LanguageTest.php @@ -638,6 +638,24 @@ class LanguageTest extends LanguageClassesTestCase { ); } + /** + * sprintfDate should only calculate a TTL if the caller is going to use it. + * @covers Language::sprintfDate + */ + public function testSprintfDateNoTtlIfNotNeeded() { + $noTtl = 'unused'; // Value used to represent that the caller didn't pass a variable in. + $ttl = null; + $this->getLang()->sprintfDate( 'YmdHis', wfTimestampNow(), null, $noTtl ); + $this->getLang()->sprintfDate( 'YmdHis', wfTimestampNow(), null, $ttl ); + + $this->assertSame( + 'unused', + $noTtl, + 'If the caller does not set the $ttl variable, do not compute it.' + ); + $this->assertInternalType( 'int', $ttl, 'TTL should have been computed.' ); + } + public static function provideSprintfDateSamples() { return [ [ @@ -1738,4 +1756,17 @@ class LanguageTest extends LanguageClassesTestCase { ], ]; } + + public function testEquals() { + $en1 = new Language(); + $en1->setCode( 'en' ); + + $en2 = Language::factory( 'en' ); + $en2->setCode( 'en' ); + + $this->assertTrue( $en1->equals( $en2 ), 'en equals en' ); + + $fr = Language::factory( 'fr' ); + $this->assertFalse( $en1->equals( $fr ), 'en not equals fr' ); + } } diff --git a/tests/phpunit/languages/classes/LanguageTrTest.php b/tests/phpunit/languages/classes/LanguageTrTest.php index 206e608047..9a1823b1cd 100644 --- a/tests/phpunit/languages/classes/LanguageTrTest.php +++ b/tests/phpunit/languages/classes/LanguageTrTest.php @@ -14,7 +14,7 @@ class LanguageTrTest extends LanguageClassesTestCase { * - berm * - []LuCkY[] * - Emperyan - * @see http://en.wikipedia.org/wiki/Dotted_and_dotless_I + * @see https://en.wikipedia.org/wiki/Dotted_and_dotless_I * @dataProvider provideDottedAndDotlessI * @covers Language::ucfirst * @covers Language::lcfirst @@ -49,7 +49,7 @@ class LanguageTrTest extends LanguageClassesTestCase { [ 'lcfirst', 'i', 'lower', 'i' ], # A real example taken from bug 28040 using - # http://tr.wikipedia.org/wiki/%C4%B0Phone + # https://tr.wikipedia.org/wiki/%C4%B0Phone [ 'lcfirst', 'iPhone', 'lower', 'iPhone' ], # next case is valid in Turkish but are different words if we diff --git a/tests/phpunit/maintenance/DumpTestCase.php b/tests/phpunit/maintenance/DumpTestCase.php index ac83d4e70a..1d55ab8434 100644 --- a/tests/phpunit/maintenance/DumpTestCase.php +++ b/tests/phpunit/maintenance/DumpTestCase.php @@ -25,6 +25,26 @@ abstract class DumpTestCase extends MediaWikiLangTestCase { */ protected $xml = null; + /** @var bool|null Whether the 'gzip' utility is available */ + protected static $hasGzip = null; + + /** + * Skip the test if 'gzip' is not in $PATH. + * + * @return bool + */ + protected function checkHasGzip() { + if ( self::$hasGzip === null ) { + self::$hasGzip = ( Installer::locateExecutableInDefaultPaths( 'gzip' ) !== false ); + } + + if ( !self::$hasGzip ) { + $this->markTestSkipped( "Skip test, requires the gzip utility in PATH" ); + } + + return self::$hasGzip; + } + /** * Adds a revision to a page, while returning the resuting revision's id * diff --git a/tests/phpunit/maintenance/MaintenanceTest.php b/tests/phpunit/maintenance/MaintenanceTest.php index bd10856943..cbf94d65d6 100644 --- a/tests/phpunit/maintenance/MaintenanceTest.php +++ b/tests/phpunit/maintenance/MaintenanceTest.php @@ -5,6 +5,7 @@ // without changing the visibility and without working around hacks in // Maintenance.php // For the same reason, we cannot just use FakeMaintenance. +use MediaWiki\MediaWikiServices; /** * makes parts of the API of Maintenance that is hidden by protected visibily @@ -826,7 +827,7 @@ class MaintenanceTest extends MediaWikiTestCase { public function testGetConfig() { $this->assertInstanceOf( 'Config', $this->m->getConfig() ); $this->assertSame( - ConfigFactory::getDefaultInstance()->makeConfig( 'main' ), + MediaWikiServices::getInstance()->getMainConfig(), $this->m->getConfig() ); } diff --git a/tests/phpunit/mocks/filebackend/MockFSFile.php b/tests/phpunit/mocks/filebackend/MockFSFile.php index bdeed58a81..6797f59554 100644 --- a/tests/phpunit/mocks/filebackend/MockFSFile.php +++ b/tests/phpunit/mocks/filebackend/MockFSFile.php @@ -50,15 +50,11 @@ class MockFSFile extends FSFile { return wfTimestamp( TS_MW ); } - public function getMimeType() { - return 'text/mock'; - } - public function getProps( $ext = true ) { return [ 'fileExists' => $this->exists(), 'size' => $this->getSize(), - 'file-mime' => $this->getMimeType(), + 'file-mime' => 'text/mock', 'sha1' => $this->getSha1Base36(), ]; } diff --git a/tests/phpunit/mocks/filerepo/MockLocalRepo.php b/tests/phpunit/mocks/filerepo/MockLocalRepo.php new file mode 100644 index 0000000000..eeaf05a0aa --- /dev/null +++ b/tests/phpunit/mocks/filerepo/MockLocalRepo.php @@ -0,0 +1,23 @@ +<?php + +/** + * Class simulating a local file repo. + * + * @ingroup FileRepo + * @since 1.28 + */ +class MockLocalRepo extends LocalRepo { + function getLocalCopy( $virtualUrl ) { + return new MockFSFile( wfTempDir() . '/' . wfRandomString( 32 ) ); + } + + function getLocalReference( $virtualUrl ) { + return new MockFSFile( wfTempDir() . '/' . wfRandomString( 32 ) ); + } + + function getFileProps( $virtualUrl ) { + $fsFile = $this->getLocalReference( $virtualUrl ); + + return $fsFile->getProps(); + } +} diff --git a/tests/phpunit/mocks/media/MockDjVuHandler.php b/tests/phpunit/mocks/media/MockDjVuHandler.php index 018d978fc3..0e0b9435cd 100644 --- a/tests/phpunit/mocks/media/MockDjVuHandler.php +++ b/tests/phpunit/mocks/media/MockDjVuHandler.php @@ -22,6 +22,10 @@ */ class MockDjVuHandler extends DjVuHandler { + function isEnabled() { + return true; + } + function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) { if ( !$this->normaliseParams( $image, $params ) ) { return new TransformParameterError( $params ); diff --git a/tests/phpunit/mocks/media/MockMediaHandlerFactory.php b/tests/phpunit/mocks/media/MockMediaHandlerFactory.php new file mode 100644 index 0000000000..54d46b0271 --- /dev/null +++ b/tests/phpunit/mocks/media/MockMediaHandlerFactory.php @@ -0,0 +1,51 @@ +<?php +/** + * Media-handling base classes and generic functionality. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @ingroup Media + */ + +/** + * Replace all media handlers with a mock. We do not need to generate + * actual thumbnails to do parser testing, we only care about receiving + * a ThumbnailImage properly initialized. + * + * @since 1.28 + */ +class MockMediaHandlerFactory extends MediaHandlerFactory { + + private static $overrides = [ + 'image/svg+xml' => MockSvgHandler::class, + 'image/vnd.djvu' => MockDjVuHandler::class, + 'application/ogg' => MockOggHandler::class, + ]; + + public function __construct() { + // override parent + } + + protected function getHandlerClass( $type ) { + if ( isset( self::$overrides[$type] ) ) { + return self::$overrides[$type]; + } + + return MockBitmapHandler::class; + } + +} diff --git a/tests/phpunit/mocks/media/MockOggHandler.php b/tests/phpunit/mocks/media/MockOggHandler.php index b110e2137f..99992fe3d8 100644 --- a/tests/phpunit/mocks/media/MockOggHandler.php +++ b/tests/phpunit/mocks/media/MockOggHandler.php @@ -76,18 +76,30 @@ class MockOggHandler extends OggHandlerTMH { } function getLength( $file ) { + if ( $this->isAudio( $file ) ) { + return 0.99875; + } return 4.3666666666667; } function getBitRate( $file ) { + if ( $this->isAudio( $file ) ) { + return 41107; + } return 590013; } function getWebType( $file ) { + if ( $this->isAudio( $file ) ) { + return "audio/ogg; codecs=\"vorbis\""; + } return "video/ogg; codecs=\"theora\""; } function getFramerate( $file ) { + if ( $this->isAudio( $file ) ) { + return 0; + } return 30; } } diff --git a/tests/phpunit/phpunit.php b/tests/phpunit/phpunit.php index d876c4578a..d8171044f3 100755 --- a/tests/phpunit/phpunit.php +++ b/tests/phpunit/phpunit.php @@ -10,23 +10,20 @@ // through this entry point or not. define( 'MW_PHPUNIT_TEST', true ); -$wgPhpUnitClass = 'PHPUnit_TextUI_Command'; - // Start up MediaWiki in command-line mode require_once dirname( dirname( __DIR__ ) ) . "/maintenance/Maintenance.php"; class PHPUnitMaintClass extends Maintenance { public static $additionalOptions = [ - 'regex' => false, 'file' => false, 'use-filebackend' => false, 'use-bagostuff' => false, 'use-jobqueue' => false, - 'keep-uploads' => false, 'use-normal-tables' => false, 'reuse-db' => false, 'wiki' => false, + 'profiler' => false, ]; public function __construct() { @@ -43,22 +40,10 @@ class PHPUnitMaintClass extends Maintenance { false, # not required false # no arg needed ); - $this->addOption( - 'regex', - 'Only run parser tests that match the given regex.', - false, - true - ); $this->addOption( 'file', 'File describing parser tests.', false, true ); $this->addOption( 'use-filebackend', 'Use filebackend', false, true ); $this->addOption( 'use-bagostuff', 'Use bagostuff', false, true ); $this->addOption( 'use-jobqueue', 'Use jobqueue', false, true ); - $this->addOption( - 'keep-uploads', - 'Re-use the same upload directory for each test, don\'t delete it.', - false, - false - ); $this->addOption( 'use-normal-tables', 'Use normal DB tables.', false, false ); $this->addOption( 'reuse-db', 'Init DB only if tables are missing and keep after finish.', @@ -70,79 +55,10 @@ class PHPUnitMaintClass extends Maintenance { public function finalSetup() { parent::finalSetup(); - global $wgMainCacheType, $wgMessageCacheType, $wgParserCacheType, $wgMainWANCache; - global $wgMainStash; - global $wgLanguageConverterCacheType, $wgUseDatabaseMessages; - global $wgLocaltimezone, $wgLocalisationCacheConf; - global $wgDevelopmentWarnings; - global $wgSessionProviders; - global $wgJobTypeConf; - // Inject test autoloader - require_once __DIR__ . '/../TestsAutoLoader.php'; - - // wfWarn should cause tests to fail - $wgDevelopmentWarnings = true; - - // Make sure all caches and stashes are either disabled or use - // in-process cache only to prevent tests from using any preconfigured - // cache meant for the local wiki from outside the test run. - // See also MediaWikiTestCase::run() which mocks CACHE_DB and APC. - - // Disabled in DefaultSettings, override local settings - $wgMainWANCache = - $wgMainCacheType = CACHE_NONE; - // Uses CACHE_ANYTHING in DefaultSettings, use hash instead of db - $wgMessageCacheType = - $wgParserCacheType = - $wgSessionCacheType = - $wgLanguageConverterCacheType = 'hash'; - // Uses db-replicated in DefaultSettings - $wgMainStash = 'hash'; - // Use memory job queue - $wgJobTypeConf = [ - 'default' => [ 'class' => 'JobQueueMemory', 'order' => 'fifo' ], - ]; - - $wgUseDatabaseMessages = false; # Set for future resets - - // Assume UTC for testing purposes - $wgLocaltimezone = 'UTC'; - - $wgLocalisationCacheConf['storeClass'] = 'LCStoreNull'; - - // Generic MediaWiki\Session\SessionManager configuration for tests - // We use CookieSessionProvider because things might be expecting - // cookies to show up in a FauxRequest somewhere. - $wgSessionProviders = [ - [ - 'class' => MediaWiki\Session\CookieSessionProvider::class, - 'args' => [ [ - 'priority' => 30, - 'callUserSetCookiesHook' => true, - ] ], - ], - ]; - - // Bug 44192 Do not attempt to send a real e-mail - Hooks::clear( 'AlternateUserMailer' ); - Hooks::register( - 'AlternateUserMailer', - function () { - return false; - } - ); - // xdebug's default of 100 is too low for MediaWiki - ini_set( 'xdebug.max_nesting_level', 1000 ); - - // Bug T116683 serialize_precision of 100 - // may break testing against floating point values - // treated with PHP's serialize() - ini_set( 'serialize_precision', 17 ); + self::requireTestsAutoloader(); - // TODO: we should call MediaWikiTestCase::prepareServices( new GlobalVarConfig() ) here. - // But PHPUnit may not be loaded yet, so we have to wait until just - // before PHPUnit_TextUI_Command::main() is executed at the end of this file. + TestSetup::applyInitialConfig(); } public function execute() { @@ -163,9 +79,10 @@ class PHPUnitMaintClass extends Maintenance { [ '--configuration', $IP . '/tests/phpunit/suite.xml' ] ); } + $phpUnitClass = 'PHPUnit_TextUI_Command'; + if ( $this->hasOption( 'with-phpunitclass' ) ) { - global $wgPhpUnitClass; - $wgPhpUnitClass = $this->getOption( 'with-phpunitclass' ); + $phpUnitClass = $this->getOption( 'with-phpunitclass' ); # Cleanup $args array so the option and its value do not # pollute PHPUnit @@ -195,6 +112,25 @@ class PHPUnitMaintClass extends Maintenance { } } + if ( !class_exists( 'PHPUnit_Framework_TestCase' ) ) { + echo "PHPUnit not found. Please install it and other dev dependencies by + running `composer install` in MediaWiki root directory.\n"; + exit( 1 ); + } + if ( !class_exists( $phpUnitClass ) ) { + echo "PHPUnit entry point '" . $phpUnitClass . "' not found. Please make sure you installed + the containing component and check the spelling of the class name.\n"; + exit( 1 ); + } + + echo defined( 'HHVM_VERSION' ) ? + 'Using HHVM ' . HHVM_VERSION . ' (' . PHP_VERSION . ")\n" : + 'Using PHP ' . PHP_VERSION . "\n"; + + // Prepare global services for unit tests. + MediaWikiTestCase::prepareServices( new GlobalVarConfig() ); + + $phpUnitClass::main(); } public function getDbType() { @@ -225,25 +161,3 @@ class PHPUnitMaintClass extends Maintenance { $maintClass = 'PHPUnitMaintClass'; require RUN_MAINTENANCE_IF_MAIN; - -if ( !class_exists( 'PHPUnit_Framework_TestCase' ) ) { - echo "PHPUnit not found. Please install it and other dev dependencies by -running `composer install` in MediaWiki root directory.\n"; - exit( 1 ); -} -if ( !class_exists( $wgPhpUnitClass ) ) { - echo "PHPUnit entry point '" . $wgPhpUnitClass . "' not found. Please make sure you installed -the containing component and check the spelling of the class name.\n"; - exit( 1 ); -} - -echo defined( 'HHVM_VERSION' ) ? - 'Using HHVM ' . HHVM_VERSION . ' (' . PHP_VERSION . ")\n" : - 'Using PHP ' . PHP_VERSION . "\n"; - -// Prepare global services for unit tests. -// FIXME: this should be done in the finalSetup() method, -// but PHPUnit may not have been loaded at that point. -MediaWikiTestCase::prepareServices( new GlobalVarConfig() ); - -$wgPhpUnitClass::main(); diff --git a/tests/phpunit/specials/SpecialSearchTest.php b/tests/phpunit/specials/SpecialSearchTest.php new file mode 100644 index 0000000000..20e88f5a5b --- /dev/null +++ b/tests/phpunit/specials/SpecialSearchTest.php @@ -0,0 +1,23 @@ +<?php + +class SpecialSearchText extends \PHPUnit_Framework_TestCase { + public function testSubPageRedirect() { + $ctx = new RequestContext; + + SpecialPageFactory::executePath( + Title::newFromText( 'Special:Search/foo_bar' ), + $ctx + ); + $url = $ctx->getOutput()->getRedirect(); + // some older versions of hhvm have a bug that doesn't parse relative + // urls with a port, so help it out a little bit. + // https://github.com/facebook/hhvm/issues/7136 + $url = wfExpandUrl( $url, PROTO_CURRENT ); + + $parts = parse_url( $url ); + $this->assertEquals( '/w/index.php', $parts['path'] ); + parse_str( $parts['query'], $query ); + $this->assertEquals( 'Special:Search', $query['title'] ); + $this->assertEquals( 'foo bar', $query['search'] ); + } +} diff --git a/tests/phpunit/structure/ApiDocumentationTest.php b/tests/phpunit/structure/ApiDocumentationTest.php index 2049e38ba0..bc5a6bd6c7 100644 --- a/tests/phpunit/structure/ApiDocumentationTest.php +++ b/tests/phpunit/structure/ApiDocumentationTest.php @@ -137,6 +137,8 @@ class ApiDocumentationTest extends MediaWikiTestCase { // Messages for examples. foreach ( $module->getExamplesMessages() as $qs => $msg ) { + $this->assertStringStartsNotWith( 'api.php?', $qs, + "Query string must not begin with 'api.php?'" ); $this->checkMessage( $msg, "Example $qs" ); } } diff --git a/tests/phpunit/structure/AutoLoaderTest.php b/tests/phpunit/structure/AutoLoaderTest.php index 58de8e8491..f36b51a7e9 100644 --- a/tests/phpunit/structure/AutoLoaderTest.php +++ b/tests/phpunit/structure/AutoLoaderTest.php @@ -143,4 +143,15 @@ class AutoLoaderTest extends MediaWikiTestCase { $this->assertFalse( $uncerealized instanceof __PHP_Incomplete_Class, "unserialize() can load classes case-insensitively." ); } + + function testAutoloadOrder() { + $path = realpath( __DIR__ . '/../../..' ); + $oldAutoload = file_get_contents( $path . '/autoload.php' ); + $generator = new AutoloadGenerator( $path, 'local' ); + $generator->initMediaWikiDefault(); + $newAutoload = $generator->getAutoload( 'maintenance/generateLocalAutoload.php' ); + + $this->assertEquals( $oldAutoload, $newAutoload, 'autoload.php does not match' . + ' output of generateLocalAutoload.php script.' ); + } } diff --git a/tests/phpunit/structure/ContentHandlerSanityTest.php b/tests/phpunit/structure/ContentHandlerSanityTest.php new file mode 100644 index 0000000000..98a0fbbfd5 --- /dev/null +++ b/tests/phpunit/structure/ContentHandlerSanityTest.php @@ -0,0 +1,53 @@ +<?php +/** + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +class ContentHandlerSanityTest extends MediaWikiTestCase { + + public static function provideHandlers() { + $models = ContentHandler::getContentModels(); + $handlers = []; + foreach ( $models as $model ) { + $handlers[] = [ ContentHandler::getForModelID( $model ) ]; + } + + return $handlers; + } + + /** + * @dataProvider provideHandlers + * @param ContentHandler $handler + */ + public function testMakeEmptyContent( ContentHandler $handler ) { + $content = $handler->makeEmptyContent(); + $this->assertInstanceOf( Content::class, $content ); + if ( $handler instanceof TextContentHandler ) { + // TextContentHandler::getContentClass() is protected, so bypass + // that restriction + $testingWrapper = TestingAccessWrapper::newFromObject( $handler ); + $this->assertInstanceOf( $testingWrapper->getContentClass(), $content ); + } + + $handlerClass = get_class( $handler ); + $contentClass = get_class( $content ); + + $this->assertTrue( + $content->isValid(), + "$handlerClass::makeEmptyContent() did not return a valid content ($contentClass::isValid())" + ); + } +} diff --git a/tests/phpunit/structure/ExtensionJsonValidationTest.php b/tests/phpunit/structure/ExtensionJsonValidationTest.php index 275c0d1794..e11fd8a548 100644 --- a/tests/phpunit/structure/ExtensionJsonValidationTest.php +++ b/tests/phpunit/structure/ExtensionJsonValidationTest.php @@ -16,6 +16,9 @@ * http://www.gnu.org/copyleft/gpl.html */ +use Composer\Spdx\SpdxLicenses; +use JsonSchema\Validator; + /** * Validates all loaded extensions and skins using the ExtensionRegistry * against the extension.json schema in the docs/ folder. @@ -24,7 +27,7 @@ class ExtensionJsonValidationTest extends PHPUnit_Framework_TestCase { public function setUp() { parent::setUp(); - if ( !class_exists( 'JsonSchema\Uri\UriRetriever' ) ) { + if ( !class_exists( Validator::class ) ) { $this->markTestSkipped( 'The JsonSchema library cannot be found,' . ' please install it through composer to run extension.json validation tests.' @@ -74,12 +77,23 @@ class ExtensionJsonValidationTest extends PHPUnit_Framework_TestCase { $version <= ExtensionRegistry::MANIFEST_VERSION, "$path is using a non-supported schema version" ); - $retriever = new JsonSchema\Uri\UriRetriever(); - $schema = $retriever->retrieve( 'file://' . $schemaPath ); - $validator = new JsonSchema\Validator(); - $validator->check( $data, $schema ); - if ( $validator->isValid() ) { + $licenseError = false; + if ( class_exists( SpdxLicenses::class ) && isset( $data->{'license-name'} ) + // Check if it's a string, if not, schema validation will display an error + && is_string( $data->{'license-name'} ) + ) { + $licenses = new SpdxLicenses(); + $valid = $licenses->validate( $data->{'license-name'} ); + if ( !$valid ) { + $licenseError = '[license-name] Invalid SPDX license identifier, ' + . 'see <https://spdx.org/licenses/>'; + } + } + + $validator = new Validator; + $validator->check( $data, (object)[ '$ref' => 'file://' . $schemaPath ] ); + if ( $validator->isValid() && !$licenseError ) { // All good. $this->assertTrue( true ); } else { @@ -87,6 +101,9 @@ class ExtensionJsonValidationTest extends PHPUnit_Framework_TestCase { foreach ( $validator->getErrors() as $error ) { $out .= "[{$error['property']}] {$error['message']}\n"; } + if ( $licenseError ) { + $out .= "$licenseError\n"; + } $this->assertTrue( false, $out ); } } diff --git a/tests/phpunit/structure/ResourcesTest.php b/tests/phpunit/structure/ResourcesTest.php index 5c65c1ef89..2e6bf37db8 100644 --- a/tests/phpunit/structure/ResourcesTest.php +++ b/tests/phpunit/structure/ResourcesTest.php @@ -40,7 +40,7 @@ class ResourcesTest extends MediaWikiTestCase { $data = self::getAllModules(); foreach ( $data['modules'] as $moduleName => $module ) { $version = $module->getVersionHash( $data['context'] ); - $this->assertEquals( 8, strlen( $version ), "$moduleName must use ResourceLoader::makeHash" ); + $this->assertEquals( 7, strlen( $version ), "$moduleName must use ResourceLoader::makeHash" ); } } @@ -86,6 +86,24 @@ class ResourcesTest extends MediaWikiTestCase { } } + /** + * Verify that all specified messages actually exist. + */ + public function testMissingMessages() { + $data = self::getAllModules(); + $lang = Language::factory( 'en' ); + + /** @var ResourceLoaderModule $module */ + foreach ( $data['modules'] as $moduleName => $module ) { + foreach ( $module->getMessages() as $msgKey ) { + $this->assertTrue( + wfMessage( $msgKey )->useDatabase( false )->inLanguage( $lang )->exists(), + "Message '$msgKey' required by '$moduleName' must exist" + ); + } + } + } + /** * Verify that all dependencies of all modules are always satisfiable with the 'targets' defined * for the involved modules. @@ -95,7 +113,6 @@ class ResourcesTest extends MediaWikiTestCase { */ public function testUnsatisfiableDependencies() { $data = self::getAllModules(); - $validDeps = array_keys( $data['modules'] ); /** @var ResourceLoaderModule $module */ foreach ( $data['modules'] as $moduleName => $module ) { diff --git a/tests/phpunit/suite.xml b/tests/phpunit/suite.xml index 63f5de0179..11a25c4105 100644 --- a/tests/phpunit/suite.xml +++ b/tests/phpunit/suite.xml @@ -16,16 +16,18 @@ beStrictAboutTestsThatDoNotTestAnything="true" beStrictAboutOutputDuringTests="true" beStrictAboutTestSize="true" - verbose="true"> + verbose="false"> <testsuites> <testsuite name="includes"> <directory>includes</directory> + <!-- Parser tests must be invoked via their suite --> + <exclude>includes/parser/ParserIntegrationTest.php</exclude> </testsuite> <testsuite name="languages"> <directory>languages</directory> </testsuite> <testsuite name="parsertests"> - <file>includes/parser/MediaWikiParserTest.php</file> + <file>suites/CoreParserTestSuite.php</file> <file>suites/ExtensionsParserTestSuite.php</file> </testsuite> <testsuite name="skins"> @@ -41,6 +43,9 @@ <testsuite name="structure"> <directory>structure</directory> </testsuite> + <testsuite name="tests"> + <directory>tests</directory> + </testsuite> <testsuite name="uploadfromurl"> <file>suites/UploadFromUrlTestSuite.php</file> </testsuite> @@ -55,7 +60,6 @@ <exclude> <group>Utility</group> <group>Broken</group> - <group>ParserFuzz</group> <group>Stub</group> </exclude> </groups> diff --git a/tests/phpunit/suites/CoreParserTestSuite.php b/tests/phpunit/suites/CoreParserTestSuite.php new file mode 100644 index 0000000000..e48a116547 --- /dev/null +++ b/tests/phpunit/suites/CoreParserTestSuite.php @@ -0,0 +1,10 @@ +<?php + +class CoreParserTestSuite extends PHPUnit_Framework_TestSuite { + + public static function suite() { + return ParserTestTopLevelSuite::suite( ParserTestTopLevelSuite::CORE_ONLY ); + } + +} + diff --git a/tests/phpunit/suites/ExtensionsParserTestSuite.php b/tests/phpunit/suites/ExtensionsParserTestSuite.php index 3d68b24198..8d6ee07956 100644 --- a/tests/phpunit/suites/ExtensionsParserTestSuite.php +++ b/tests/phpunit/suites/ExtensionsParserTestSuite.php @@ -2,7 +2,7 @@ class ExtensionsParserTestSuite extends PHPUnit_Framework_TestSuite { public static function suite() { - return MediaWikiParserTest::suite( MediaWikiParserTest::NO_CORE ); + return ParserTestTopLevelSuite::suite( ParserTestTopLevelSuite::NO_CORE ); } } diff --git a/tests/phpunit/suites/ExtensionsTestSuite.php b/tests/phpunit/suites/ExtensionsTestSuite.php index 0e23fdde82..02934fa7bd 100644 --- a/tests/phpunit/suites/ExtensionsTestSuite.php +++ b/tests/phpunit/suites/ExtensionsTestSuite.php @@ -8,10 +8,16 @@ class ExtensionsTestSuite extends PHPUnit_Framework_TestSuite { public function __construct() { parent::__construct(); + $paths = []; + // Autodiscover extension unit tests + $registry = ExtensionRegistry::getInstance(); + foreach ( $registry->getAllThings() as $info ) { + $paths[] = dirname( $info['path'] ) . '/tests/phpunit'; + } // Extensions can return a list of files or directories Hooks::run( 'UnitTestsList', [ &$paths ] ); - foreach ( $paths as $path ) { + foreach ( array_unique( $paths ) as $path ) { if ( is_dir( $path ) ) { // If the path is a directory, search for test cases. // @since 1.24 @@ -19,7 +25,7 @@ class ExtensionsTestSuite extends PHPUnit_Framework_TestSuite { $fileIterator = new File_Iterator_Facade(); $matchingFiles = $fileIterator->getFilesAsArray( $path, $suffixes ); $this->addTestFiles( $matchingFiles ); - } else { + } elseif ( file_exists( $path ) ) { // Add a single test case or suite class $this->addTestFile( $path ); } diff --git a/tests/phpunit/suites/ParserTestFileSuite.php b/tests/phpunit/suites/ParserTestFileSuite.php new file mode 100644 index 0000000000..dbee8947f9 --- /dev/null +++ b/tests/phpunit/suites/ParserTestFileSuite.php @@ -0,0 +1,28 @@ +<?php + +/** + * This is the suite class for running tests within a single .txt source file. + * It is not invoked directly. Use --filter to select files, or + * use parserTests.php. + */ +class ParserTestFileSuite extends PHPUnit_Framework_TestSuite { + private $ptRunner; + private $ptFileName; + private $ptFileInfo; + + function __construct( $runner, $name, $fileName ) { + parent::__construct( $name ); + $this->ptRunner = $runner; + $this->ptFileName = $fileName; + $this->ptFileInfo = TestFileReader::read( $this->ptFileName ); + + foreach ( $this->ptFileInfo['tests'] as $test ) { + $this->addTest( new ParserIntegrationTest( $runner, $fileName, $test ), + [ 'Database', 'Parser', 'ParserTests' ] ); + } + } + + function setUp() { + $this->ptRunner->addArticles( $this->ptFileInfo[ 'articles'] ); + } +} diff --git a/tests/phpunit/suites/ParserTestTopLevelSuite.php b/tests/phpunit/suites/ParserTestTopLevelSuite.php new file mode 100644 index 0000000000..5d5d693571 --- /dev/null +++ b/tests/phpunit/suites/ParserTestTopLevelSuite.php @@ -0,0 +1,158 @@ +<?php +use Wikimedia\ScopedCallback; + +/** + * The UnitTest must be either a class that inherits from MediaWikiTestCase + * or a class that provides a public static suite() method which returns + * an PHPUnit_Framework_Test object + * + * @group Parser + * @group ParserTests + * @group Database + */ +class ParserTestTopLevelSuite extends PHPUnit_Framework_TestSuite { + /** @var ParserTestRunner */ + private $ptRunner; + + /** @var ScopedCallback */ + private $ptTeardownScope; + + /** + * @defgroup filtering_constants Filtering constants + * + * Limit inclusion of parser tests files coming from MediaWiki core + * @{ + */ + + /** Include files shipped with MediaWiki core */ + const CORE_ONLY = 1; + /** Include non core files as set in $wgParserTestFiles */ + const NO_CORE = 2; + /** Include anything set via $wgParserTestFiles */ + const WITH_ALL = 3; # CORE_ONLY | NO_CORE + + /** @} */ + + /** + * Get a PHPUnit test suite of parser tests. Optionally filtered with + * $flags. + * + * @par Examples: + * Get a suite of parser tests shipped by MediaWiki core: + * @code + * ParserTestTopLevelSuite::suite( ParserTestTopLevelSuite::CORE_ONLY ); + * @endcode + * Get a suite of various parser tests, like extensions: + * @code + * ParserTestTopLevelSuite::suite( ParserTestTopLevelSuite::NO_CORE ); + * @endcode + * Get any test defined via $wgParserTestFiles: + * @code + * ParserTestTopLevelSuite::suite( ParserTestTopLevelSuite::WITH_ALL ); + * @endcode + * + * @param int $flags Bitwise flag to filter out the $wgParserTestFiles that + * will be included. Default: ParserTestTopLevelSuite::CORE_ONLY + * + * @return PHPUnit_Framework_TestSuite + */ + public static function suite( $flags = self::CORE_ONLY ) { + return new self( $flags ); + } + + function __construct( $flags ) { + parent::__construct(); + + $this->ptRecorder = new PhpunitTestRecorder; + $this->ptRunner = new ParserTestRunner( $this->ptRecorder ); + + if ( is_string( $flags ) ) { + $flags = self::CORE_ONLY; + } + global $wgParserTestFiles, $IP; + + $mwTestDir = $IP . '/tests/'; + + # Human friendly helpers + $wantsCore = ( $flags & self::CORE_ONLY ); + $wantsRest = ( $flags & self::NO_CORE ); + + # Will hold the .txt parser test files we will include + $filesToTest = []; + + # Filter out .txt files + foreach ( $wgParserTestFiles as $parserTestFile ) { + $isCore = ( 0 === strpos( $parserTestFile, $mwTestDir ) ); + + if ( $isCore && $wantsCore ) { + self::debug( "included core parser tests: $parserTestFile" ); + $filesToTest[] = $parserTestFile; + } elseif ( !$isCore && $wantsRest ) { + self::debug( "included non core parser tests: $parserTestFile" ); + $filesToTest[] = $parserTestFile; + } else { + self::debug( "skipped parser tests: $parserTestFile" ); + } + } + self::debug( 'parser tests files: ' + . implode( ' ', $filesToTest ) ); + + $testList = []; + $counter = 0; + foreach ( $filesToTest as $fileName ) { + // Call the highest level directory the extension name. + // It may or may not actually be, but it should be close + // enough to cause there to be separate names for different + // things, which is good enough for our purposes. + $extensionName = basename( dirname( $fileName ) ); + $testsName = $extensionName . '__' . basename( $fileName, '.txt' ); + $parserTestClassName = ucfirst( $testsName ); + + // Official spec for class names: https://secure.php.net/manual/en/language.oop5.basic.php + // Prepend 'ParserTest_' to be paranoid about it not starting with a number + $parserTestClassName = 'ParserTest_' . + preg_replace( '/[^a-zA-Z0-9_\x7f-\xff]/', '_', $parserTestClassName ); + + if ( isset( $testList[$parserTestClassName] ) ) { + // If there is a conflict, append a number. + $counter++; + $parserTestClassName .= $counter; + } + $testList[$parserTestClassName] = true; + + // Previously we actually created a class here, with eval(). We now + // just override the name. + + self::debug( "Adding test class $parserTestClassName" ); + $this->addTest( new ParserTestFileSuite( + $this->ptRunner, $parserTestClassName, $fileName ) ); + } + } + + public function setUp() { + wfDebug( __METHOD__ ); + $db = wfGetDB( DB_MASTER ); + $type = $db->getType(); + $prefix = $type === 'oracle' ? + MediaWikiTestCase::ORA_DB_PREFIX : MediaWikiTestCase::DB_PREFIX; + MediaWikiTestCase::setupTestDB( $db, $prefix ); + $teardown = $this->ptRunner->setDatabase( $db ); + $teardown = $this->ptRunner->setupUploads( $teardown ); + $this->ptTeardownScope = $teardown; + } + + public function tearDown() { + wfDebug( __METHOD__ ); + if ( $this->ptTeardownScope ) { + ScopedCallback::consume( $this->ptTeardownScope ); + } + } + + /** + * Write $msg under log group 'tests-parser' + * @param string $msg Message to log + */ + protected static function debug( $msg ) { + return wfDebugLog( 'tests-parser', wfGetCaller() . ' ' . $msg ); + } +} diff --git a/tests/phpunit/tests/MediaWikiTestCaseTest.php b/tests/phpunit/tests/MediaWikiTestCaseTest.php index 5d2f37e6a6..edc81ff925 100644 --- a/tests/phpunit/tests/MediaWikiTestCaseTest.php +++ b/tests/phpunit/tests/MediaWikiTestCaseTest.php @@ -9,8 +9,6 @@ use Psr\Log\LoggerInterface; */ class MediaWikiTestCaseTest extends MediaWikiTestCase { - const GLOBAL_KEY_NONEXISTING = 'MediaWikiTestCaseTestGLOBAL-NONExisting'; - private static $startGlobals = [ 'MediaWikiTestCaseTestGLOBAL-ExistingString' => 'foo', 'MediaWikiTestCaseTestGLOBAL-ExistingStringEmpty' => '', @@ -90,14 +88,22 @@ class MediaWikiTestCaseTest extends MediaWikiTestCase { /** * @covers MediaWikiTestCase::stashMwGlobals + * @covers MediaWikiTestCase::tearDown */ - public function testExceptionThrownWhenStashingNonExistentGlobals() { - $this->setExpectedException( - 'Exception', - 'Global with key ' . self::GLOBAL_KEY_NONEXISTING . ' doesn\'t exist and cant be stashed' + public function testSetNonExistentGlobalsAreUnsetOnTearDown() { + $globalKey = 'abcdefg1234567'; + $this->setMwGlobals( $globalKey, true ); + $this->assertTrue( + $GLOBALS[$globalKey], + 'Global failed to correctly set' ); - $this->stashMwGlobals( self::GLOBAL_KEY_NONEXISTING ); + $this->tearDown(); + + $this->assertFalse( + isset( $GLOBALS[$globalKey] ), + 'Global failed to be correctly unset' + ); } public function testOverrideMwServices() { @@ -132,10 +138,9 @@ class MediaWikiTestCaseTest extends MediaWikiTestCase { /** * @covers MediaWikiTestCase::setLogger - * @covers MediaWikiTestCase::restoreLogger + * @covers MediaWikiTestCase::restoreLoggers */ - public function testLoggersAreRestoredOnTearDown() { - // replacing an existing logger + public function testLoggersAreRestoredOnTearDown_replacingExistingLogger() { $logger1 = LoggerFactory::getInstance( 'foo' ); $this->setLogger( 'foo', $this->getMock( LoggerInterface::class ) ); $logger2 = LoggerFactory::getInstance( 'foo' ); @@ -144,17 +149,27 @@ class MediaWikiTestCaseTest extends MediaWikiTestCase { $this->assertSame( $logger1, $logger3 ); $this->assertNotSame( $logger1, $logger2 ); + } - // replacing a non-existing logger + /** + * @covers MediaWikiTestCase::setLogger + * @covers MediaWikiTestCase::restoreLoggers + */ + public function testLoggersAreRestoredOnTearDown_replacingNonExistingLogger() { $this->setLogger( 'foo', $this->getMock( LoggerInterface::class ) ); - $logger1 = LoggerFactory::getInstance( 'bar' ); + $logger1 = LoggerFactory::getInstance( 'foo' ); $this->tearDown(); - $logger2 = LoggerFactory::getInstance( 'bar' ); + $logger2 = LoggerFactory::getInstance( 'foo' ); $this->assertNotSame( $logger1, $logger2 ); $this->assertInstanceOf( '\Psr\Log\LoggerInterface', $logger2 ); + } - // replacing same logger twice + /** + * @covers MediaWikiTestCase::setLogger + * @covers MediaWikiTestCase::restoreLoggers + */ + public function testLoggersAreRestoredOnTearDown_replacingSameLoggerTwice() { $logger1 = LoggerFactory::getInstance( 'baz' ); $this->setLogger( 'foo', $this->getMock( LoggerInterface::class ) ); $this->setLogger( 'foo', $this->getMock( LoggerInterface::class ) ); diff --git a/tests/qunit/QUnitTestResources.php b/tests/qunit/QUnitTestResources.php index a2d76e01b0..e30088dbc8 100644 --- a/tests/qunit/QUnitTestResources.php +++ b/tests/qunit/QUnitTestResources.php @@ -74,6 +74,7 @@ return [ 'tests/qunit/suites/resources/mediawiki/mediawiki.template.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.template.mustache.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.html.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.toc.test.js', @@ -84,6 +85,7 @@ return [ 'tests/qunit/suites/resources/mediawiki/mediawiki.viewport.test.js', 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js', 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.category.test.js', + 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.edit.test.js', 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.messages.test.js', 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.options.test.js', 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js', diff --git a/tests/qunit/data/defineCallMwLoaderTestCallback.js b/tests/qunit/data/defineCallMwLoaderTestCallback.js index afd886c334..641071a220 100644 --- a/tests/qunit/data/defineCallMwLoaderTestCallback.js +++ b/tests/qunit/data/defineCallMwLoaderTestCallback.js @@ -1 +1 @@ -module.exports = 'Define worked.'; +module.exports = 'Defined.'; diff --git a/tests/qunit/data/requireCallMwLoaderTestCallback.js b/tests/qunit/data/requireCallMwLoaderTestCallback.js index 8bc087b007..815a3b4868 100644 --- a/tests/qunit/data/requireCallMwLoaderTestCallback.js +++ b/tests/qunit/data/requireCallMwLoaderTestCallback.js @@ -1,2 +1,6 @@ -var x = require( 'test.require.define' ); -module.exports = 'Require worked.' + x; +module.exports = { + immediate: require( 'test.require.define' ), + later: function () { + return require( 'test.require.define' ); + } +}; diff --git a/tests/qunit/data/testrunner.js b/tests/qunit/data/testrunner.js index 1091d093f2..0b28684a59 100644 --- a/tests/qunit/data/testrunner.js +++ b/tests/qunit/data/testrunner.js @@ -1,5 +1,4 @@ /*global CompletenessTest, sinon */ -/*jshint evil: true */ ( function ( $, mw, QUnit ) { 'use strict'; @@ -26,6 +25,9 @@ // killing the test and assuming timeout failure. QUnit.config.testTimeout = 60 * 1000; + // Reduce default animation duration from 400ms to 0ms for unit tests + $.fx.speeds._default = 0; + // Add a checkbox to QUnit header to toggle MediaWiki ResourceLoader debug mode. QUnit.config.urlConfig.push( { id: 'debug', @@ -168,10 +170,11 @@ */ QUnit.newMwEnvironment = ( function () { var warn, error, liveConfig, liveMessages, + MwMap = mw.config.constructor, // internal use only ajaxRequests = []; - liveConfig = mw.config.values; - liveMessages = mw.messages.values; + liveConfig = mw.config; + liveMessages = mw.messages; function suppressWarnings() { warn = mw.log.warn; @@ -199,14 +202,14 @@ // NOTE: It is important that we suppress warnings because extend() will also access // deprecated properties and trigger deprecation warnings from mw.log#deprecate. suppressWarnings(); - copy = $.extend( {}, liveConfig, custom ); + copy = $.extend( {}, liveConfig.get(), custom ); restoreWarnings(); return copy; } function freshMessagesCopy( custom ) { - return $.extend( /*deep=*/true, {}, liveMessages, custom ); + return $.extend( /*deep=*/true, {}, liveMessages.get(), custom ); } /** @@ -232,8 +235,15 @@ setup: function () { // Greetings, mock environment! - mw.config.values = freshConfigCopy( localEnv.config ); - mw.messages.values = freshMessagesCopy( localEnv.messages ); + mw.config = new MwMap(); + mw.config.set( freshConfigCopy( localEnv.config ) ); + mw.messages = new MwMap(); + mw.messages.set( freshMessagesCopy( localEnv.messages ) ); + // Update reference to mw.messages + mw.jqueryMsg.setParserDefaults( { + messages: mw.messages + } ); + this.suppressWarnings = suppressWarnings; this.restoreWarnings = restoreWarnings; @@ -252,8 +262,12 @@ $( document ).off( 'ajaxSend', trackAjax ); // Farewell, mock environment! - mw.config.values = liveConfig; - mw.messages.values = liveMessages; + mw.config = liveConfig; + mw.messages = liveMessages; + // Restore reference to mw.messages + mw.jqueryMsg.setParserDefaults( { + messages: liveMessages + } ); // As a convenience feature, automatically restore warnings if they're // still suppressed by the end of the test. diff --git a/tests/qunit/suites/resources/jquery/jquery.color.test.js b/tests/qunit/suites/resources/jquery/jquery.color.test.js index 9afd793e5b..ca6a512fc5 100644 --- a/tests/qunit/suites/resources/jquery/jquery.color.test.js +++ b/tests/qunit/suites/resources/jquery/jquery.color.test.js @@ -1,18 +1,15 @@ ( function ( $ ) { - QUnit.module( 'jquery.color', QUnit.newMwEnvironment( { - setup: function () { - this.clock = this.sandbox.useFakeTimers(); - } - } ) ); + QUnit.module( 'jquery.color', QUnit.newMwEnvironment() ); - QUnit.test( 'animate', 1, function ( assert ) { - var $canvas = $( '<div>' ).css( 'background-color', '#fff' ); + QUnit.test( 'animate', function ( assert ) { + var done = assert.async(), + $canvas = $( '<div>' ).css( 'background-color', '#fff' ).appendTo( '#qunit-fixture' ); - $canvas.animate( { backgroundColor: '#000' }, 10 ).promise().then( function () { - var endColors = $.colorUtil.getRGB( $canvas.css( 'background-color' ) ); - assert.deepEqual( endColors, [ 0, 0, 0 ], 'end state' ); - } ); - - this.clock.tick( 20 ); + $canvas.animate( { 'background-color': '#000' }, 3 ).promise() + .done( function () { + var endColors = $.colorUtil.getRGB( $canvas.css( 'background-color' ) ); + assert.deepEqual( endColors, [ 0, 0, 0 ], 'end state' ); + } ) + .always( done ); } ); }( jQuery ) ); diff --git a/tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js b/tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js index c51e40931c..9c7660fe8d 100644 --- a/tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js +++ b/tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js @@ -1,11 +1,7 @@ ( function ( mw, $ ) { var loremIpsum = 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit.'; - QUnit.module( 'jquery.makeCollapsible', QUnit.newMwEnvironment( { - setup: function () { - this.clock = this.sandbox.useFakeTimers(); - } - } ) ); + QUnit.module( 'jquery.makeCollapsible', QUnit.newMwEnvironment() ); function prepareCollapsible( html, options ) { return $( $.parseHTML( html ) ) @@ -42,12 +38,10 @@ // ...expanding happens here $toggle.trigger( 'click' ); - test.clock.tick( 500 ); } ); // ...collapsing happens here $toggle.trigger( 'click' ); - test.clock.tick( 500 ); } ); QUnit.test( 'basic operation (<div>)', 5, function ( assert ) { @@ -71,11 +65,9 @@ } ); $toggle.trigger( 'click' ); - test.clock.tick( 500 ); } ); $toggle.trigger( 'click' ); - test.clock.tick( 500 ); } ); QUnit.test( 'basic operation (<table>)', 7, function ( assert ) { @@ -106,11 +98,9 @@ } ); $toggle.trigger( 'click' ); - test.clock.tick( 500 ); } ); $toggle.trigger( 'click' ); - test.clock.tick( 500 ); } ); function tableWithCaptionTest( $collapsible, test, assert ) { @@ -137,11 +127,9 @@ } ); $toggle.trigger( 'click' ); - test.clock.tick( 500 ); } ); $toggle.trigger( 'click' ); - test.clock.tick( 500 ); } QUnit.test( 'basic operation (<table> with caption)', 10, function ( assert ) { @@ -192,11 +180,9 @@ } ); $toggle.trigger( 'click' ); - test.clock.tick( 500 ); } ); $toggle.trigger( 'click' ); - test.clock.tick( 500 ); } QUnit.test( 'basic operation (<ul>)', 7, function ( assert ) { @@ -260,7 +246,6 @@ } ); $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' ); - this.clock.tick( 500 ); } ); QUnit.test( 'initial collapse (options.collapsed)', 2, function ( assert ) { @@ -278,7 +263,6 @@ } ); $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' ); - this.clock.tick( 500 ); } ); QUnit.test( 'clicks on links inside toggler pass through (options.linksPassthru)', 2, function ( assert ) { @@ -316,7 +300,6 @@ } ); $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' ); - this.clock.tick( 500 ); } ); QUnit.test( 'collapse/expand text (options.collapseText, options.expandText)', 2, function ( assert ) { @@ -333,7 +316,6 @@ } ); $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' ); - this.clock.tick( 500 ); } ); QUnit.test( 'cloned collapsibles can be made collapsible again', 2, function ( assert ) { @@ -352,6 +334,5 @@ } ); $clone.find( '.mw-collapsible-toggle a' ).trigger( 'click' ); - test.clock.tick( 500 ); } ); }( mediaWiki, jQuery ) ); diff --git a/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js b/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js index b09bb2825b..ca26aaf904 100644 --- a/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js +++ b/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js @@ -81,6 +81,24 @@ [ 'Strasse' ] ], + // Data set "digraph" + digraphWords = [ + [ 'London' ], + [ 'Ljubljana' ], + [ 'Luxembourg' ], + [ 'Njivice' ], + [ 'Norwich' ], + [ 'New York' ] + ], + digraphWordsSorted = [ + [ 'London' ], + [ 'Luxembourg' ], + [ 'Ljubljana' ], + [ 'New York' ], + [ 'Norwich' ], + [ 'Njivice' ] + ], + complexMDYDates = [ [ 'January, 19 2010' ], [ 'April 21 1991' ], @@ -697,6 +715,22 @@ } ); + tableTest( + 'Digraphs with custom collation', + [ 'City' ], + digraphWords, + digraphWordsSorted, + function ( $table ) { + mw.config.set( 'tableSorterCollation', { + lj: 'lzzzz', + nj: 'nzzzz' + } ); + + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + QUnit.test( 'Rowspan not exploded on init', 1, function ( assert ) { var $table = tableCreate( header, planets ); diff --git a/tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js b/tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js index 9d0fdf54b0..1676130e98 100644 --- a/tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js +++ b/tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js @@ -3,16 +3,10 @@ setup: function () { this.server = this.sandbox.useFakeServer(); this.server.respondImmediately = true; - this.clock = this.sandbox.useFakeTimers(); - }, - teardown: function () { - // https://github.com/jquery/jquery/issues/2453 - this.clock.tick(); } } ) ); - QUnit.test( 'origin is included in GET requests', function ( assert ) { - QUnit.expect( 1 ); + QUnit.test( 'origin is included in GET requests', 1, function ( assert ) { var api = new mw.ForeignApi( '//localhost:4242/w/api.php' ); this.server.respond( function ( request ) { @@ -20,11 +14,10 @@ request.respond( 200, { 'Content-Type': 'application/json' }, '[]' ); } ); - api.get( {} ); + return api.get( {} ); } ); - QUnit.test( 'origin is included in POST requests', function ( assert ) { - QUnit.expect( 2 ); + QUnit.test( 'origin is included in POST requests', 2, function ( assert ) { var api = new mw.ForeignApi( '//localhost:4242/w/api.php' ); this.server.respond( function ( request ) { @@ -33,7 +26,7 @@ request.respond( 200, { 'Content-Type': 'application/json' }, '[]' ); } ); - api.post( {} ); + return api.post( {} ); } ); }( mediaWiki ) ); diff --git a/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.category.test.js b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.category.test.js index a0c7daf1a0..a79bff698b 100644 --- a/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.category.test.js +++ b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.category.test.js @@ -2,29 +2,24 @@ QUnit.module( 'mediawiki.api.category', QUnit.newMwEnvironment( { setup: function () { this.server = this.sandbox.useFakeServer(); + this.server.respondImmediately = true; } } ) ); - QUnit.test( '.getCategoriesByPrefix()', function ( assert ) { - QUnit.expect( 1 ); + QUnit.test( '.getCategoriesByPrefix()', 1, function ( assert ) { + this.server.respondWith( [ 200, { 'Content-Type': 'application/json' }, + '{ "query": { "allpages": [ ' + + '{ "title": "Category:Food" },' + + '{ "title": "Category:Fool Supermarine S.6" },' + + '{ "title": "Category:Fools" }' + + '] } }' + ] ); - var api = new mw.Api(); - - api.getCategoriesByPrefix( 'Foo' ).done( function ( matches ) { + return new mw.Api().getCategoriesByPrefix( 'Foo' ).then( function ( matches ) { assert.deepEqual( matches, [ 'Food', 'Fool Supermarine S.6', 'Fools' ] ); } ); - - this.server.respond( function ( req ) { - req.respond( 200, { 'Content-Type': 'application/json' }, - '{ "query": { "allpages": [ ' + - '{ "title": "Category:Food" },' + - '{ "title": "Category:Fool Supermarine S.6" },' + - '{ "title": "Category:Fools" }' + - '] } }' - ); - } ); } ); }( mediaWiki ) ); diff --git a/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.edit.test.js b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.edit.test.js new file mode 100644 index 0000000000..f83f66cc34 --- /dev/null +++ b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.edit.test.js @@ -0,0 +1,153 @@ +( function ( mw, $ ) { + QUnit.module( 'mediawiki.api.edit', QUnit.newMwEnvironment( { + setup: function () { + this.server = this.sandbox.useFakeServer(); + this.server.respondImmediately = true; + } + } ) ); + + QUnit.test( 'edit( title, transform String )', function ( assert ) { + this.server.respond( function ( req ) { + if ( /query.+titles=Sandbox/.test( req.url ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( { + curtimestamp: '2016-01-02T12:00:00Z', + query: { + pages: [ { + pageid: 1, + ns: 0, + title: 'Sandbox', + revisions: [ { + timestamp: '2016-01-01T12:00:00Z', + contentformat: 'text/x-wiki', + contentmodel: 'wikitext', + content: 'Sand.' + } ] + } ] + } + } ) ); + } + if ( /edit.+basetimestamp=2016-01-01.+starttimestamp=2016-01-02.+text=Box%2E/.test( req.requestBody ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( { + edit: { + result: 'Success', + oldrevid: 11, + newrevid: 13, + newtimestamp: '2016-01-03T12:00:00Z' + } + } ) ); + } + } ); + + return new mw.Api() + .edit( 'Sandbox', function ( revision ) { + return revision.content.replace( 'Sand', 'Box' ); + } ) + .then( function ( edit ) { + assert.equal( edit.newrevid, 13 ); + } ); + } ); + + QUnit.test( 'edit( title, transform Promise )', function ( assert ) { + this.server.respond( function ( req ) { + if ( /query.+titles=Async/.test( req.url ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( { + curtimestamp: '2016-02-02T12:00:00Z', + query: { + pages: [ { + pageid: 4, + ns: 0, + title: 'Async', + revisions: [ { + timestamp: '2016-02-01T12:00:00Z', + contentformat: 'text/x-wiki', + contentmodel: 'wikitext', + content: 'Async.' + } ] + } ] + } + } ) ); + } + if ( /edit.+basetimestamp=2016-02-01.+starttimestamp=2016-02-02.+text=Promise%2E/.test( req.requestBody ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( { + edit: { + result: 'Success', + oldrevid: 21, + newrevid: 23, + newtimestamp: '2016-02-03T12:00:00Z' + } + } ) ); + } + } ); + + return new mw.Api() + .edit( 'Async', function ( revision ) { + return $.Deferred().resolve( revision.content.replace( 'Async', 'Promise' ) ); + } ) + .then( function ( edit ) { + assert.equal( edit.newrevid, 23 ); + } ); + } ); + + QUnit.test( 'edit( title, transform Object )', function ( assert ) { + this.server.respond( function ( req ) { + if ( /query.+titles=Param/.test( req.url ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( { + curtimestamp: '2016-03-02T12:00:00Z', + query: { + pages: [ { + pageid: 3, + ns: 0, + title: 'Param', + revisions: [ { + timestamp: '2016-03-01T12:00:00Z', + contentformat: 'text/x-wiki', + contentmodel: 'wikitext', + content: '...' + } ] + } ] + } + } ) ); + } + if ( /edit.+basetimestamp=2016-03-01.+starttimestamp=2016-03-02.+text=Content&summary=Sum/.test( req.requestBody ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( { + edit: { + result: 'Success', + oldrevid: 31, + newrevid: 33, + newtimestamp: '2016-03-03T12:00:00Z' + } + } ) ); + } + } ); + + return new mw.Api() + .edit( 'Param', function () { + return { text: 'Content', summary: 'Sum' }; + } ) + .then( function ( edit ) { + assert.equal( edit.newrevid, 33 ); + } ); + } ); + + QUnit.test( 'create( title, content )', function ( assert ) { + this.server.respond( function ( req ) { + if ( /edit.+text=Sand/.test( req.requestBody ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( { + edit: { + 'new': true, + result: 'Success', + newrevid: 41, + newtimestamp: '2016-04-01T12:00:00Z' + } + } ) ); + } + } ); + + return new mw.Api() + .create( 'Sandbox', { summary: 'Load sand particles.' }, 'Sand.' ) + .then( function ( page ) { + assert.equal( page.newrevid, 41 ); + } ); + } ); + +}( mediaWiki, jQuery ) ); diff --git a/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.messages.test.js b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.messages.test.js index 5880962a74..d8b5db88a3 100644 --- a/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.messages.test.js +++ b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.messages.test.js @@ -2,14 +2,21 @@ QUnit.module( 'mediawiki.api.messages', QUnit.newMwEnvironment( { setup: function () { this.server = this.sandbox.useFakeServer(); + this.server.respondImmediately = true; } } ) ); - QUnit.test( '.getMessages()', function ( assert ) { - QUnit.expect( 1 ); + QUnit.test( '.getMessages()', 1, function ( assert ) { + this.server.respondWith( /ammessages=foo%7Cbaz/, [ + 200, + { 'Content-Type': 'application/json' }, + '{ "query": { "allmessages": [' + + '{ "name": "foo", "content": "Foo bar" },' + + '{ "name": "baz", "content": "Baz Quux" }' + + '] } }' + ] ); - var api = new mw.Api(); - api.getMessages( [ 'foo', 'baz' ] ).then( function ( messages ) { + return new mw.Api().getMessages( [ 'foo', 'baz' ] ).then( function ( messages ) { assert.deepEqual( messages, { @@ -18,14 +25,5 @@ } ); } ); - - this.server.respond( /ammessages=foo%7Cbaz/, [ - 200, - { 'Content-Type': 'application/json' }, - '{ "query": { "allmessages": [' + - '{ "name": "foo", "content": "Foo bar" },' + - '{ "name": "baz", "content": "Baz Quux" }' + - '] } }' - ] ); } ); }( mediaWiki ) ); diff --git a/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.options.test.js b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.options.test.js index a48067125f..7ed1875036 100644 --- a/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.options.test.js +++ b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.options.test.js @@ -2,14 +2,12 @@ QUnit.module( 'mediawiki.api.options', QUnit.newMwEnvironment( { setup: function () { this.server = this.sandbox.useFakeServer(); + this.server.respondImmediately = true; } } ) ); - QUnit.test( 'saveOption', function ( assert ) { - QUnit.expect( 2 ); - - var - api = new mw.Api(), + QUnit.test( 'saveOption', 2, function ( assert ) { + var api = new mw.Api(), stub = this.sandbox.stub( mw.Api.prototype, 'saveOptions' ); api.saveOption( 'foo', 'bar' ); @@ -18,10 +16,8 @@ assert.deepEqual( stub.getCall( 0 ).args, [ { foo: 'bar' } ], '#saveOptions called correctly' ); } ); - QUnit.test( 'saveOptions', function ( assert ) { - QUnit.expect( 13 ); - - var api = new mw.Api(); + QUnit.test( 'saveOptions without Unit Separator', 13, function ( assert ) { + var api = new mw.Api( { useUS: false } ); // We need to respond to the request for token first, otherwise the other requests won't be sent // until after the server.respond call, which confuses sinon terribly. This sucks a lot. @@ -32,25 +28,6 @@ '{ "query": { "tokens": { "csrftoken": "+\\\\" } } }' ] ); - api.saveOptions( {} ).done( function () { - assert.ok( true, 'Request completed: empty case' ); - } ); - api.saveOptions( { foo: 'bar' } ).done( function () { - assert.ok( true, 'Request completed: simple' ); - } ); - api.saveOptions( { foo: 'bar', baz: 'quux' } ).done( function () { - assert.ok( true, 'Request completed: two options' ); - } ); - api.saveOptions( { foo: 'bar|quux', bar: 'a|b|c', baz: 'quux' } ).done( function () { - assert.ok( true, 'Request completed: not bundleable' ); - } ); - api.saveOptions( { foo: null } ).done( function () { - assert.ok( true, 'Request completed: reset an option' ); - } ); - api.saveOptions( { 'foo|bar=quux': null } ).done( function () { - assert.ok( true, 'Request completed: reset an option, not bundleable' ); - } ); - // Requests are POST, match requestBody instead of url this.server.respond( function ( request ) { switch ( request.requestBody ) { @@ -74,5 +51,88 @@ assert.ok( false, 'Unexpected request: ' + request.requestBody ); } } ); + + return QUnit.whenPromisesComplete( + api.saveOptions( {} ).then( function () { + assert.ok( true, 'Request completed: empty case' ); + } ), + api.saveOptions( { foo: 'bar' } ).then( function () { + assert.ok( true, 'Request completed: simple' ); + } ), + api.saveOptions( { foo: 'bar', baz: 'quux' } ).then( function () { + assert.ok( true, 'Request completed: two options' ); + } ), + api.saveOptions( { foo: 'bar|quux', bar: 'a|b|c', baz: 'quux' } ).then( function () { + assert.ok( true, 'Request completed: not bundleable' ); + } ), + api.saveOptions( { foo: null } ).then( function () { + assert.ok( true, 'Request completed: reset an option' ); + } ), + api.saveOptions( { 'foo|bar=quux': null } ).then( function () { + assert.ok( true, 'Request completed: reset an option, not bundleable' ); + } ) + ); + } ); + + QUnit.test( 'saveOptions with Unit Separator', 14, function ( assert ) { + var api = new mw.Api( { useUS: true } ); + + // We need to respond to the request for token first, otherwise the other requests won't be sent + // until after the server.respond call, which confuses sinon terribly. This sucks a lot. + api.getToken( 'options' ); + this.server.respond( + /meta=tokens&type=csrf/, + [ 200, { 'Content-Type': 'application/json' }, + '{ "query": { "tokens": { "csrftoken": "+\\\\" } } }' ] + ); + + // Requests are POST, match requestBody instead of url + this.server.respond( function ( request ) { + switch ( request.requestBody ) { + // simple + case 'action=options&format=json&formatversion=2&change=foo%3Dbar&token=%2B%5C': + // two options + case 'action=options&format=json&formatversion=2&change=foo%3Dbar%7Cbaz%3Dquux&token=%2B%5C': + // bundleable with unit separator + case 'action=options&format=json&formatversion=2&change=%1Ffoo%3Dbar%7Cquux%1Fbar%3Da%7Cb%7Cc%1Fbaz%3Dquux&token=%2B%5C': + // not bundleable with unit separator + case 'action=options&format=json&formatversion=2&optionname=baz%3Dbaz&optionvalue=quux&token=%2B%5C': + case 'action=options&format=json&formatversion=2&change=%1Ffoo%3Dbar%7Cquux%1Fbar%3Da%7Cb%7Cc&token=%2B%5C': + // reset an option + case 'action=options&format=json&formatversion=2&change=foo&token=%2B%5C': + // reset an option, not bundleable + case 'action=options&format=json&formatversion=2&optionname=foo%7Cbar%3Dquux&token=%2B%5C': + assert.ok( true, 'Repond to ' + request.requestBody ); + request.respond( 200, { 'Content-Type': 'application/json' }, + '{ "options": "success" }' ); + break; + default: + assert.ok( false, 'Unexpected request: ' + request.requestBody ); + } + } ); + + return QUnit.whenPromisesComplete( + api.saveOptions( {} ).done( function () { + assert.ok( true, 'Request completed: empty case' ); + } ), + api.saveOptions( { foo: 'bar' } ).done( function () { + assert.ok( true, 'Request completed: simple' ); + } ), + api.saveOptions( { foo: 'bar', baz: 'quux' } ).done( function () { + assert.ok( true, 'Request completed: two options' ); + } ), + api.saveOptions( { foo: 'bar|quux', bar: 'a|b|c', baz: 'quux' } ).done( function () { + assert.ok( true, 'Request completed: bundleable with unit separator' ); + } ), + api.saveOptions( { foo: 'bar|quux', bar: 'a|b|c', 'baz=baz': 'quux' } ).done( function () { + assert.ok( true, 'Request completed: not bundleable with unit separator' ); + } ), + api.saveOptions( { foo: null } ).done( function () { + assert.ok( true, 'Request completed: reset an option' ); + } ), + api.saveOptions( { 'foo|bar=quux': null } ).done( function () { + assert.ok( true, 'Request completed: reset an option, not bundleable' ); + } ) + ); } ); }( mediaWiki ) ); diff --git a/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js index dc0cff40e6..7d27352200 100644 --- a/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js +++ b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js @@ -2,42 +2,44 @@ QUnit.module( 'mediawiki.api.parse', QUnit.newMwEnvironment( { setup: function () { this.server = this.sandbox.useFakeServer(); + this.server.respondImmediately = true; } } ) ); - QUnit.test( 'Hello world', function ( assert ) { - QUnit.expect( 3 ); + QUnit.test( '.parse( string )', function ( assert ) { + this.server.respondWith( /action=parse.*&text='''Hello(\+|%20)world'''/, [ 200, + { 'Content-Type': 'application/json' }, + '{ "parse": { "text": "<p><b>Hello world</b></p>" } }' + ] ); - var api = new mw.Api(); - - api.parse( '\'\'\'Hello world\'\'\'' ).done( function ( html ) { + return new mw.Api().parse( '\'\'\'Hello world\'\'\'' ).done( function ( html ) { assert.equal( html, '<p><b>Hello world</b></p>', 'Parse wikitext by string' ); } ); + } ); - api.parse( { + QUnit.test( '.parse( Object.toString )', function ( assert ) { + this.server.respondWith( /action=parse.*&text='''Hello(\+|%20)world'''/, [ 200, + { 'Content-Type': 'application/json' }, + '{ "parse": { "text": "<p><b>Hello world</b></p>" } }' + ] ); + + return new mw.Api().parse( { toString: function () { return '\'\'\'Hello world\'\'\''; } } ).done( function ( html ) { assert.equal( html, '<p><b>Hello world</b></p>', 'Parse wikitext by toString object' ); } ); + } ); - this.server.respondWith( /action=parse.*&text='''Hello\+world'''/, function ( request ) { - request.respond( 200, { 'Content-Type': 'application/json' }, - '{ "parse": { "text": "<p><b>Hello world</b></p>" } }' - ); - } ); + QUnit.test( '.parse( mw.Title )', function ( assert ) { + this.server.respondWith( /action=parse.*&page=Earth/, [ 200, + { 'Content-Type': 'application/json' }, + '{ "parse": { "text": "<p><b>Earth</b> is a planet.</p>" } }' + ] ); - api.parse( new mw.Title( 'Earth' ) ).done( function ( html ) { + return new mw.Api().parse( new mw.Title( 'Earth' ) ).done( function ( html ) { assert.equal( html, '<p><b>Earth</b> is a planet.</p>', 'Parse page by Title object' ); } ); - - this.server.respondWith( /action=parse.*&page=Earth/, function ( request ) { - request.respond( 200, { 'Content-Type': 'application/json' }, - '{ "parse": { "text": "<p><b>Earth</b> is a planet.</p>" } }' - ); - } ); - - this.server.respond(); } ); }( mediaWiki ) ); diff --git a/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.upload.test.js b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.upload.test.js index 10fcd5da68..b1bd12ba17 100644 --- a/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.upload.test.js +++ b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.upload.test.js @@ -1,8 +1,7 @@ ( function ( mw, $ ) { QUnit.module( 'mediawiki.api.upload', QUnit.newMwEnvironment( {} ) ); - QUnit.test( 'Basic functionality', function ( assert ) { - QUnit.expect( 2 ); + QUnit.test( 'Basic functionality', 2, function ( assert ) { var api = new mw.Api(); assert.ok( api.upload ); assert.throws( function () { @@ -10,8 +9,7 @@ } ); } ); - QUnit.test( 'Set up iframe upload', function ( assert ) { - QUnit.expect( 5 ); + QUnit.test( 'Set up iframe upload', 5, function ( assert ) { var $iframe, $form, $input, api = new mw.Api(); diff --git a/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.watch.test.js b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.watch.test.js index 64a5184711..86414691d8 100644 --- a/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.watch.test.js +++ b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.watch.test.js @@ -2,37 +2,45 @@ QUnit.module( 'mediawiki.api.watch', QUnit.newMwEnvironment( { setup: function () { this.server = this.sandbox.useFakeServer(); + this.server.respondImmediately = true; } } ) ); - QUnit.test( '.watch()', function ( assert ) { - QUnit.expect( 4 ); - - var api = new mw.Api(); - - // Ensure we don't mistake a single item array for a single item and vice versa. - // The query parameter in request is the same either way (separated by pipe). - api.watch( 'Foo' ).done( function ( item ) { - assert.equal( item.title, 'Foo' ); - } ); - - api.watch( [ 'Foo' ] ).done( function ( items ) { - assert.equal( items[ 0 ].title, 'Foo' ); + QUnit.test( '.watch( string )', function ( assert ) { + this.server.respond( function ( req ) { + // Match POST requestBody + if ( /action=watch.*&titles=Foo(&|$)/.test( req.requestBody ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, + '{ "watch": [ { "title": "Foo", "watched": true, "message": "<b>Added</b>" } ] }' + ); + } } ); - api.watch( [ 'Foo', 'Bar' ] ).done( function ( items ) { - assert.equal( items[ 0 ].title, 'Foo' ); - assert.equal( items[ 1 ].title, 'Bar' ); + return new mw.Api().watch( 'Foo' ).done( function ( item ) { + assert.equal( item.title, 'Foo' ); } ); + } ); - // Requests are POST, match requestBody instead of url + // Ensure we don't mistake a single item array for a single item and vice versa. + // The query parameter in request is the same either way (separated by pipe). + QUnit.test( '.watch( Array ) - single', function ( assert ) { this.server.respond( function ( req ) { + // Match POST requestBody if ( /action=watch.*&titles=Foo(&|$)/.test( req.requestBody ) ) { req.respond( 200, { 'Content-Type': 'application/json' }, '{ "watch": [ { "title": "Foo", "watched": true, "message": "<b>Added</b>" } ] }' ); } + } ); + + return new mw.Api().watch( [ 'Foo' ] ).done( function ( items ) { + assert.equal( items[ 0 ].title, 'Foo' ); + } ); + } ); + QUnit.test( '.watch( Array ) - multi', function ( assert ) { + this.server.respond( function ( req ) { + // Match POST requestBody if ( /action=watch.*&titles=Foo%7CBar/.test( req.requestBody ) ) { req.respond( 200, { 'Content-Type': 'application/json' }, '{ "watch": [ ' + @@ -42,5 +50,11 @@ ); } } ); + + return new mw.Api().watch( [ 'Foo', 'Bar' ] ).done( function ( items ) { + assert.equal( items[ 0 ].title, 'Foo' ); + assert.equal( items[ 1 ].title, 'Bar' ); + } ); } ); + }( mediaWiki ) ); diff --git a/tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js b/tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js index ee854aef4e..edc2716e5b 100644 --- a/tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js +++ b/tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js @@ -4,7 +4,8 @@ // TODO: verify checkboxes == [ 'nsassociated', 'nsinvert' ] QUnit.test( '"all" namespace disable checkboxes', 8, function ( assert ) { - var selectHtml, $env, $options; + var selectHtml, $env, $options, + rc = require( 'mediawiki.special.recentchanges' ); // from Special:Recentchanges selectHtml = '<select id="namespace" name="namespace" class="namespaceselector">' @@ -32,7 +33,7 @@ assert.strictEqual( $( '#nsassociated' ).prop( 'disabled' ), false ); // Initiate the recentchanges module - mw.special.recentchanges.init(); + rc.init(); // By default assert.strictEqual( $( '#nsinvert' ).prop( 'disabled' ), true ); diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js index 991725b8c6..124c49f5b8 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js @@ -1,4 +1,3 @@ -/*jshint -W024 */ ( function ( mw, $ ) { var repeat = function ( input, multiplier ) { return new Array( multiplier + 1 ).join( input ); @@ -38,6 +37,8 @@ 'A < B', 'A > B', 'A | B', + 'A \t B', + 'A \n B', // URL encoding 'A%20B', 'A%23B', @@ -222,7 +223,7 @@ assert.equal( title.getPrefixedText(), '.foo' ); } ); - QUnit.test( 'Transformation', 11, function ( assert ) { + QUnit.test( 'Transformation', 12, function ( assert ) { var title; title = new mw.Title( 'File:quux pif.jpg' ); @@ -242,10 +243,12 @@ assert.equal( title.toText(), 'User:HAshAr' ); assert.equal( title.getNamespaceId(), 2, 'Case-insensitive namespace prefix' ); - // Don't ask why, it's the way the backend works. One space is kept of each set. - title = new mw.Title( 'Foo __ \t __ bar' ); + title = new mw.Title( 'Foo \u00A0\u1680\u180E\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000 bar' ); assert.equal( title.getMain(), 'Foo_bar', 'Merge multiple types of whitespace/underscores into a single underscore' ); + title = new mw.Title( 'Foo\u200E\u200F\u202A\u202B\u202C\u202D\u202Ebar' ); + assert.equal( title.getMain(), 'Foobar', 'Strip Unicode bidi override characters' ); + // Regression test: Previously it would only detect an extension if there is no space after it title = new mw.Title( 'Example.js ' ); assert.equal( title.getExtension(), 'js', 'Space after an extension is stripped' ); @@ -295,7 +298,7 @@ }, 'Throw error on empty string' ); } ); - QUnit.test( 'Case-sensivity', 3, function ( assert ) { + QUnit.test( 'Case-sensivity', 5, function ( assert ) { var title; // Default config @@ -304,6 +307,12 @@ title = new mw.Title( 'article' ); assert.equal( title.toString(), 'Article', 'Default config: No sensitive namespaces by default. First-letter becomes uppercase' ); + title = new mw.Title( 'ß' ); + assert.equal( title.toString(), 'ß', 'Uppercasing matches PHP behaviour (ß -> ß, not SS)' ); + + title = new mw.Title( 'dž (digraph)' ); + assert.equal( title.toString(), 'Dž_(digraph)', 'Uppercasing matches PHP behaviour (dž -> Dž, not DŽ)' ); + // $wgCapitalLinks = false; mw.config.set( 'wgCaseSensitiveNamespaces', [ 0, -2, 1, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15 ] ); diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.Uri.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.Uri.test.js index b12803d6c7..97185fca73 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.Uri.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.Uri.test.js @@ -1,4 +1,3 @@ -/*jshint -W024 */ ( function ( mw, $ ) { QUnit.module( 'mediawiki.Uri', QUnit.newMwEnvironment( { setup: function () { diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js index ee948bb806..f848f3edc8 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js @@ -1,6 +1,6 @@ ( function ( mw, $ ) { var formatText, formatParse, formatnumTests, specialCharactersPageName, expectedListUsers, - expectedListUsersSitename, expectedEntrypoints, + expectedListUsersSitename, expectedLinkPagenamee, expectedEntrypoints, mwLanguageCache = {}, hasOwn = Object.hasOwnProperty; @@ -16,6 +16,8 @@ this.parserDefaults = mw.jqueryMsg.getParserDefaults(); mw.jqueryMsg.setParserDefaults( { magic: { + PAGENAME: '2 + 2', + PAGENAMEE: mw.util.wikiUrlencode( '2 + 2' ), SITENAME: 'Wiki' } } ); @@ -25,6 +27,7 @@ expectedListUsers = '注册<a title="Special:ListUsers" href="/wiki/Special:ListUsers">用户</a>'; expectedListUsersSitename = '注册<a title="Special:ListUsers" href="/wiki/Special:ListUsers">用户' + 'Wiki</a>'; + expectedLinkPagenamee = '<a href="https://example.org/wiki/Foo?bar=baz#val/2_%2B_2">Test</a>'; expectedEntrypoints = '<a href="https://www.mediawiki.org/wiki/Manual:index.php">index.php</a>'; @@ -77,6 +80,7 @@ 'jquerymsg-test-statistics-users': '注册[[Special:ListUsers|用户]]', 'jquerymsg-test-statistics-users-sitename': '注册[[Special:ListUsers|用户{{SITENAME}}]]', + 'jquerymsg-test-link-pagenamee': '[https://example.org/wiki/Foo?bar=baz#val/{{PAGENAMEE}} Test]', 'jquerymsg-test-version-entrypoints-index-php': '[https://www.mediawiki.org/wiki/Manual:index.php index.php]', @@ -363,8 +367,8 @@ QUnit.test( 'Match PHP parser', mw.libs.phpParserData.tests.length, function ( assert ) { mw.messages.set( mw.libs.phpParserData.messages ); var tasks = $.map( mw.libs.phpParserData.tests, function ( test ) { + var done = assert.async(); return function ( next, abort ) { - var done = assert.async(); getMwLanguage( test.lang ) .then( function ( langClass ) { mw.config.set( 'wgUserLanguage', test.lang ); @@ -385,7 +389,7 @@ process( tasks ); } ); - QUnit.test( 'Links', 14, function ( assert ) { + QUnit.test( 'Links', 15, function ( assert ) { var testCases, expectedDisambiguationsText, expectedMultipleBars, @@ -468,6 +472,12 @@ 'Piped wikilink with parser function in the text' ); + assert.htmlEqual( + formatParse( 'jquerymsg-test-link-pagenamee' ), + expectedLinkPagenamee, + 'External link with parser function in the URL' + ); + testCases = [ [ 'extlink-html-full', @@ -652,7 +662,7 @@ ); assert.htmlEqual( formatParse( 'external-link-replace', function () {} ), - 'Foo <a href="#">bar</a>', + 'Foo <a role="button" tabindex="0">bar</a>', 'External link message processed as function when format is \'parse\'' ); @@ -696,7 +706,7 @@ assert.equal( formatParse( 'uses-missing-int' ), - '[doesnt-exist]', + '⧼doesnt-exist⧽', 'int: where nested message does not exist' ); } ); @@ -874,7 +884,7 @@ }, { lang: 'hi', - number: '१२३४५६,७८९', + number: '१,२३,४५६', result: '123456', integer: true, description: 'formatnum test for Hindi, Devanagari digits passed to get integer value' @@ -885,8 +895,8 @@ mw.messages.set( 'formatnum-msg', '{{formatnum:$1}}' ); mw.messages.set( 'formatnum-msg-int', '{{formatnum:$1|R}}' ); var queue = $.map( formatnumTests, function ( test ) { + var done = assert.async(); return function ( next, abort ) { - var done = assert.async(); getMwLanguage( test.lang ) .then( function ( langClass ) { mw.config.set( 'wgUserLanguage', test.lang ); @@ -908,7 +918,7 @@ } ); // HTML in wikitext - QUnit.test( 'HTML', 32, function ( assert ) { + QUnit.test( 'HTML', 33, function ( assert ) { mw.messages.set( 'jquerymsg-italics-msg', '<i>Very</i> important' ); assertBothModes( assert, [ 'jquerymsg-italics-msg' ], mw.messages.get( 'jquerymsg-italics-msg' ), 'Simple italics unchanged' ); @@ -1048,6 +1058,13 @@ 'Self-closing tags don\'t cause a parse error' ); + mw.messages.set( 'jquerymsg-asciialphabetliteral-regression', '<b >>>="dir">asd</b>' ); + assert.htmlEqual( + formatParse( 'jquerymsg-asciialphabetliteral-regression' ), + '<b>&gt;&gt;="dir"&gt;asd</b>', + 'Regression test for bad "asciiAlphabetLiteral" definition' + ); + mw.messages.set( 'jquerymsg-entities1', 'A&B' ); mw.messages.set( 'jquerymsg-entities2', 'A&gt;B' ); mw.messages.set( 'jquerymsg-entities3', 'A&rarr;B' ); @@ -1087,6 +1104,29 @@ ); } ); + QUnit.test( 'Nowiki', 3, function ( assert ) { + mw.messages.set( 'jquerymsg-nowiki-link', 'Foo <nowiki>[[bar]]</nowiki> baz.' ); + assert.equal( + formatParse( 'jquerymsg-nowiki-link' ), + 'Foo [[bar]] baz.', + 'Link inside nowiki is not parsed' + ); + + mw.messages.set( 'jquerymsg-nowiki-htmltag', 'Foo <nowiki><b>bar</b></nowiki> baz.' ); + assert.equal( + formatParse( 'jquerymsg-nowiki-htmltag' ), + 'Foo &lt;b&gt;bar&lt;/b&gt; baz.', + 'HTML inside nowiki is not parsed and escaped' + ); + + mw.messages.set( 'jquerymsg-nowiki-template', 'Foo <nowiki>{{bar}}</nowiki> baz.' ); + assert.equal( + formatParse( 'jquerymsg-nowiki-template' ), + 'Foo {{bar}} baz.', + 'Template inside nowiki is not parsed and does not cause a parse error' + ); + } ); + QUnit.test( 'Behavior in case of invalid wikitext', 3, function ( assert ) { mw.messages.set( 'invalid-wikitext', '<b>{{FAIL}}</b>' ); diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js index e4c3851593..b2fac3c941 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js @@ -43,6 +43,16 @@ assert.equal( mw.language.commafy( 123456789.567, '###,###,#0.00' ), '1,234,567,89.56', 'Decimal part as group of 3 and last one 2' ); } ); + QUnit.test( 'mw.language.convertNumber', 2, function ( assert ) { + mw.language.setData( 'en', 'digitGroupingPattern', null ); + mw.language.setData( 'en', 'digitTransformTable', null ); + mw.language.setData( 'en', 'separatorTransformTable', { ',': '.', '.': ',' } ); + mw.config.set( 'wgUserLanguage', 'en' ); + + assert.equal( mw.language.convertNumber( 1800 ), '1.800', 'formatting' ); + assert.equal( mw.language.convertNumber( '1.800', true ), '1800', 'unformatting' ); + } ); + function grammarTest( langCode, test ) { // The test works only if the content language is opt.language // because it requires [lang].js to be loaded. diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js new file mode 100644 index 0000000000..92ee7dd360 --- /dev/null +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js @@ -0,0 +1,810 @@ +( function ( mw, $ ) { + QUnit.module( 'mediawiki (mw.loader)', QUnit.newMwEnvironment( { + setup: function () { + mw.loader.store.enabled = false; + }, + teardown: function () { + mw.loader.store.enabled = false; + } + } ) ); + + mw.loader.addSource( + 'testloader', + QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/load.mock.php' ) + ); + + /** + * The sync style load test (for @import). This is, in a way, also an open bug for + * ResourceLoader ("execute js after styles are loaded"), but browsers don't offer a + * way to get a callback from when a stylesheet is loaded (that is, including any + * `@import` rules inside). To work around this, we'll have a little time loop to check + * if the styles apply. + * + * Note: This test originally used new Image() and onerror to get a callback + * when the url is loaded, but that is fragile since it doesn't monitor the + * same request as the css @import, and Safari 4 has issues with + * onerror/onload not being fired at all in weird cases like this. + */ + function assertStyleAsync( assert, $element, prop, val, fn ) { + var styleTestStart, + el = $element.get( 0 ), + styleTestTimeout = ( QUnit.config.testTimeout || 5000 ) - 200; + + function isCssImportApplied() { + // Trigger reflow, repaint, redraw, whatever (cross-browser) + var x = $element.css( 'height' ); + x = el.innerHTML; + el.className = el.className; + x = document.documentElement.clientHeight; + + return $element.css( prop ) === val; + } + + function styleTestLoop() { + var styleTestSince = new Date().getTime() - styleTestStart; + // If it is passing or if we timed out, run the real test and stop the loop + if ( isCssImportApplied() || styleTestSince > styleTestTimeout ) { + assert.equal( $element.css( prop ), val, + 'style "' + prop + ': ' + val + '" from url is applied (after ' + styleTestSince + 'ms)' + ); + + if ( fn ) { + fn(); + } + + return; + } + // Otherwise, keep polling + setTimeout( styleTestLoop ); + } + + // Start the loop + styleTestStart = new Date().getTime(); + styleTestLoop(); + } + + function urlStyleTest( selector, prop, val ) { + return QUnit.fixurl( + mw.config.get( 'wgScriptPath' ) + + '/tests/qunit/data/styleTest.css.php?' + + $.param( { + selector: selector, + prop: prop, + val: val + } ) + ); + } + + QUnit.test( 'Basic', 2, function ( assert ) { + var isAwesomeDone; + + mw.loader.testCallback = function () { + assert.strictEqual( isAwesomeDone, undefined, 'Implementing module is.awesome: isAwesomeDone should still be undefined' ); + isAwesomeDone = true; + }; + + mw.loader.implement( 'test.callback', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/callMwLoaderTestCallback.js' ) ] ); + + return mw.loader.using( 'test.callback', function () { + assert.strictEqual( isAwesomeDone, true, 'test.callback module should\'ve caused isAwesomeDone to be true' ); + delete mw.loader.testCallback; + + }, function () { + assert.ok( false, 'Error callback fired while loader.using "test.callback" module' ); + } ); + } ); + + QUnit.test( 'Object method as module name', 2, function ( assert ) { + var isAwesomeDone; + + mw.loader.testCallback = function () { + assert.strictEqual( isAwesomeDone, undefined, 'Implementing module hasOwnProperty: isAwesomeDone should still be undefined' ); + isAwesomeDone = true; + }; + + mw.loader.implement( 'hasOwnProperty', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/callMwLoaderTestCallback.js' ) ], {}, {} ); + + return mw.loader.using( 'hasOwnProperty', function () { + assert.strictEqual( isAwesomeDone, true, 'hasOwnProperty module should\'ve caused isAwesomeDone to be true' ); + delete mw.loader.testCallback; + + }, function () { + assert.ok( false, 'Error callback fired while loader.using "hasOwnProperty" module' ); + } ); + } ); + + QUnit.test( '.using( .. ) Promise', 2, function ( assert ) { + var isAwesomeDone; + + mw.loader.testCallback = function () { + assert.strictEqual( isAwesomeDone, undefined, 'Implementing module is.awesome: isAwesomeDone should still be undefined' ); + isAwesomeDone = true; + }; + + mw.loader.implement( 'test.promise', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/callMwLoaderTestCallback.js' ) ] ); + + return mw.loader.using( 'test.promise' ) + .done( function () { + assert.strictEqual( isAwesomeDone, true, 'test.promise module should\'ve caused isAwesomeDone to be true' ); + delete mw.loader.testCallback; + } ) + .fail( function () { + assert.ok( false, 'Error callback fired while loader.using "test.promise" module' ); + } ); + } ); + + QUnit.test( '.using() Error: Circular dependency', function ( assert ) { + var done = assert.async(); + + mw.loader.register( [ + [ 'test.circle1', '0', [ 'test.circle2' ] ], + [ 'test.circle2', '0', [ 'test.circle3' ] ], + [ 'test.circle3', '0', [ 'test.circle1' ] ] + ] ); + mw.loader.using( 'test.circle3' ).then( + function done() { + assert.ok( false, 'Unexpected resolution, expected error.' ); + }, + function fail( e ) { + assert.ok( /Circular/.test( String( e ) ), 'Detect circular dependency' ); + } + ) + .always( done ); + } ); + + QUnit.test( '.load() - Error: Circular dependency', function ( assert ) { + mw.loader.register( [ + [ 'test.circleA', '0', [ 'test.circleB' ] ], + [ 'test.circleB', '0', [ 'test.circleC' ] ], + [ 'test.circleC', '0', [ 'test.circleA' ] ] + ] ); + assert.throws( function () { + mw.loader.load( 'test.circleC' ); + }, /Circular/, 'Detect circular dependency' ); + } ); + + QUnit.test( '.using() - Error: Unregistered', function ( assert ) { + var done = assert.async(); + + mw.loader.using( 'test.using.unreg' ).then( + function done() { + assert.ok( false, 'Unexpected resolution, expected error.' ); + }, + function fail( e ) { + assert.ok( /Unknown/.test( String( e ) ), 'Detect unknown dependency' ); + } + ).always( done ); + } ); + + QUnit.test( '.load() - Error: Unregistered (ignored)', 0, function ( assert ) { + mw.loader.load( 'test.using.unreg2' ); + } ); + + QUnit.test( '.implement( styles={ "css": [text, ..] } )', 2, function ( assert ) { + var $element = $( '<div class="mw-test-implement-a"></div>' ).appendTo( '#qunit-fixture' ); + + assert.notEqual( + $element.css( 'float' ), + 'right', + 'style is clear' + ); + + mw.loader.implement( + 'test.implement.a', + function () { + assert.equal( + $element.css( 'float' ), + 'right', + 'style is applied' + ); + }, + { + all: '.mw-test-implement-a { float: right; }' + } + ); + + return mw.loader.using( 'test.implement.a' ); + } ); + + QUnit.test( '.implement( styles={ "url": { <media>: [url, ..] } } )', 7, function ( assert ) { + var $element1 = $( '<div class="mw-test-implement-b1"></div>' ).appendTo( '#qunit-fixture' ), + $element2 = $( '<div class="mw-test-implement-b2"></div>' ).appendTo( '#qunit-fixture' ), + $element3 = $( '<div class="mw-test-implement-b3"></div>' ).appendTo( '#qunit-fixture' ), + done = assert.async(); + + assert.notEqual( + $element1.css( 'text-align' ), + 'center', + 'style is clear' + ); + assert.notEqual( + $element2.css( 'float' ), + 'left', + 'style is clear' + ); + assert.notEqual( + $element3.css( 'text-align' ), + 'right', + 'style is clear' + ); + + mw.loader.implement( + 'test.implement.b', + function () { + // Note: done() must only be called when the entire test is + // complete. So, make sure that we don't start until *both* + // assertStyleAsync calls have completed. + var pending = 2; + assertStyleAsync( assert, $element2, 'float', 'left', function () { + assert.notEqual( $element1.css( 'text-align' ), 'center', 'print style is not applied' ); + + pending--; + if ( pending === 0 ) { + done(); + } + } ); + assertStyleAsync( assert, $element3, 'float', 'right', function () { + assert.notEqual( $element1.css( 'text-align' ), 'center', 'print style is not applied' ); + + pending--; + if ( pending === 0 ) { + done(); + } + } ); + }, + { + url: { + print: [ urlStyleTest( '.mw-test-implement-b1', 'text-align', 'center' ) ], + screen: [ + // bug 40834: Make sure it actually works with more than 1 stylesheet reference + urlStyleTest( '.mw-test-implement-b2', 'float', 'left' ), + urlStyleTest( '.mw-test-implement-b3', 'float', 'right' ) + ] + } + } + ); + + mw.loader.load( 'test.implement.b' ); + } ); + + // Backwards compatibility + QUnit.test( '.implement( styles={ <media>: text } ) (back-compat)', 2, function ( assert ) { + var $element = $( '<div class="mw-test-implement-c"></div>' ).appendTo( '#qunit-fixture' ); + + assert.notEqual( + $element.css( 'float' ), + 'right', + 'style is clear' + ); + + mw.loader.implement( + 'test.implement.c', + function () { + assert.equal( + $element.css( 'float' ), + 'right', + 'style is applied' + ); + }, + { + all: '.mw-test-implement-c { float: right; }' + } + ); + + return mw.loader.using( 'test.implement.c' ); + } ); + + // Backwards compatibility + QUnit.test( '.implement( styles={ <media>: [url, ..] } ) (back-compat)', 4, function ( assert ) { + var $element = $( '<div class="mw-test-implement-d"></div>' ).appendTo( '#qunit-fixture' ), + $element2 = $( '<div class="mw-test-implement-d2"></div>' ).appendTo( '#qunit-fixture' ), + done = assert.async(); + + assert.notEqual( + $element.css( 'float' ), + 'right', + 'style is clear' + ); + assert.notEqual( + $element2.css( 'text-align' ), + 'center', + 'style is clear' + ); + + mw.loader.implement( + 'test.implement.d', + function () { + assertStyleAsync( assert, $element, 'float', 'right', function () { + assert.notEqual( $element2.css( 'text-align' ), 'center', 'print style is not applied (bug 40500)' ); + done(); + } ); + }, + { + all: [ urlStyleTest( '.mw-test-implement-d', 'float', 'right' ) ], + print: [ urlStyleTest( '.mw-test-implement-d2', 'text-align', 'center' ) ] + } + ); + + mw.loader.load( 'test.implement.d' ); + } ); + + // @import (bug 31676) + QUnit.test( '.implement( styles has @import )', 7, function ( assert ) { + var isJsExecuted, $element, + done = assert.async(); + + mw.loader.implement( + 'test.implement.import', + function () { + assert.strictEqual( isJsExecuted, undefined, 'script not executed multiple times' ); + isJsExecuted = true; + + assert.equal( mw.loader.getState( 'test.implement.import' ), 'executing', 'module state during implement() script execution' ); + + $element = $( '<div class="mw-test-implement-import">Foo bar</div>' ).appendTo( '#qunit-fixture' ); + + assert.equal( mw.msg( 'test-foobar' ), 'Hello Foobar, $1!', 'messages load before script execution' ); + + assertStyleAsync( assert, $element, 'float', 'right', function () { + assert.equal( $element.css( 'text-align' ), 'center', + 'CSS styles after the @import rule are working' + ); + + done(); + } ); + }, + { + css: [ + '@import url(\'' + + urlStyleTest( '.mw-test-implement-import', 'float', 'right' ) + + '\');\n' + + '.mw-test-implement-import { text-align: center; }' + ] + }, + { + 'test-foobar': 'Hello Foobar, $1!' + } + ); + + mw.loader.using( 'test.implement.import' ).always( function () { + assert.strictEqual( isJsExecuted, true, 'script executed' ); + assert.equal( mw.loader.getState( 'test.implement.import' ), 'ready', 'module state after script execution' ); + } ); + } ); + + QUnit.test( '.implement( dependency with styles )', 4, function ( assert ) { + var $element = $( '<div class="mw-test-implement-e"></div>' ).appendTo( '#qunit-fixture' ), + $element2 = $( '<div class="mw-test-implement-e2"></div>' ).appendTo( '#qunit-fixture' ); + + assert.notEqual( + $element.css( 'float' ), + 'right', + 'style is clear' + ); + assert.notEqual( + $element2.css( 'float' ), + 'left', + 'style is clear' + ); + + mw.loader.register( [ + [ 'test.implement.e', '0', [ 'test.implement.e2' ] ], + [ 'test.implement.e2', '0' ] + ] ); + + mw.loader.implement( + 'test.implement.e', + function () { + assert.equal( + $element.css( 'float' ), + 'right', + 'Depending module\'s style is applied' + ); + }, + { + all: '.mw-test-implement-e { float: right; }' + } + ); + + mw.loader.implement( + 'test.implement.e2', + function () { + assert.equal( + $element2.css( 'float' ), + 'left', + 'Dependency\'s style is applied' + ); + }, + { + all: '.mw-test-implement-e2 { float: left; }' + } + ); + + return mw.loader.using( 'test.implement.e' ); + } ); + + QUnit.test( '.implement( only scripts )', 1, function ( assert ) { + mw.loader.implement( 'test.onlyscripts', function () {} ); + assert.strictEqual( mw.loader.getState( 'test.onlyscripts' ), 'ready' ); + } ); + + QUnit.test( '.implement( only messages )', 2, function ( assert ) { + assert.assertFalse( mw.messages.exists( 'bug_29107' ), 'Verify that the test message doesn\'t exist yet' ); + + // jscs: disable requireCamelCaseOrUpperCaseIdentifiers + mw.loader.implement( 'test.implement.msgs', [], {}, { bug_29107: 'loaded' } ); + // jscs: enable requireCamelCaseOrUpperCaseIdentifiers + + return mw.loader.using( 'test.implement.msgs', function () { + assert.ok( mw.messages.exists( 'bug_29107' ), 'Bug 29107: messages-only module should implement ok' ); + }, function () { + assert.ok( false, 'Error callback fired while implementing "test.implement.msgs" module' ); + } ); + } ); + + QUnit.test( '.implement( empty )', 1, function ( assert ) { + mw.loader.implement( 'test.empty' ); + assert.strictEqual( mw.loader.getState( 'test.empty' ), 'ready' ); + } ); + + QUnit.test( 'Broken indirect dependency', 4, function ( assert ) { + // don't emit an error event + this.sandbox.stub( mw, 'track' ); + + mw.loader.register( [ + [ 'test.module1', '0' ], + [ 'test.module2', '0', [ 'test.module1' ] ], + [ 'test.module3', '0', [ 'test.module2' ] ] + ] ); + mw.loader.implement( 'test.module1', function () { + throw new Error( 'expected' ); + }, {}, {} ); + assert.strictEqual( mw.loader.getState( 'test.module1' ), 'error', 'Expected "error" state for test.module1' ); + assert.strictEqual( mw.loader.getState( 'test.module2' ), 'error', 'Expected "error" state for test.module2' ); + assert.strictEqual( mw.loader.getState( 'test.module3' ), 'error', 'Expected "error" state for test.module3' ); + + assert.strictEqual( mw.track.callCount, 1 ); + } ); + + QUnit.test( 'Out-of-order implementation', 9, function ( assert ) { + mw.loader.register( [ + [ 'test.module4', '0' ], + [ 'test.module5', '0', [ 'test.module4' ] ], + [ 'test.module6', '0', [ 'test.module5' ] ] + ] ); + mw.loader.implement( 'test.module4', function () {} ); + assert.strictEqual( mw.loader.getState( 'test.module4' ), 'ready', 'Expected "ready" state for test.module4' ); + assert.strictEqual( mw.loader.getState( 'test.module5' ), 'registered', 'Expected "registered" state for test.module5' ); + assert.strictEqual( mw.loader.getState( 'test.module6' ), 'registered', 'Expected "registered" state for test.module6' ); + mw.loader.implement( 'test.module6', function () {} ); + assert.strictEqual( mw.loader.getState( 'test.module4' ), 'ready', 'Expected "ready" state for test.module4' ); + assert.strictEqual( mw.loader.getState( 'test.module5' ), 'registered', 'Expected "registered" state for test.module5' ); + assert.strictEqual( mw.loader.getState( 'test.module6' ), 'loaded', 'Expected "loaded" state for test.module6' ); + mw.loader.implement( 'test.module5', function () {} ); + assert.strictEqual( mw.loader.getState( 'test.module4' ), 'ready', 'Expected "ready" state for test.module4' ); + assert.strictEqual( mw.loader.getState( 'test.module5' ), 'ready', 'Expected "ready" state for test.module5' ); + assert.strictEqual( mw.loader.getState( 'test.module6' ), 'ready', 'Expected "ready" state for test.module6' ); + } ); + + QUnit.test( 'Missing dependency', 13, function ( assert ) { + mw.loader.register( [ + [ 'test.module7', '0' ], + [ 'test.module8', '0', [ 'test.module7' ] ], + [ 'test.module9', '0', [ 'test.module8' ] ] + ] ); + mw.loader.implement( 'test.module8', function () {} ); + assert.strictEqual( mw.loader.getState( 'test.module7' ), 'registered', 'Expected "registered" state for test.module7' ); + assert.strictEqual( mw.loader.getState( 'test.module8' ), 'loaded', 'Expected "loaded" state for test.module8' ); + assert.strictEqual( mw.loader.getState( 'test.module9' ), 'registered', 'Expected "registered" state for test.module9' ); + mw.loader.state( 'test.module7', 'missing' ); + assert.strictEqual( mw.loader.getState( 'test.module7' ), 'missing', 'Expected "missing" state for test.module7' ); + assert.strictEqual( mw.loader.getState( 'test.module8' ), 'error', 'Expected "error" state for test.module8' ); + assert.strictEqual( mw.loader.getState( 'test.module9' ), 'error', 'Expected "error" state for test.module9' ); + mw.loader.implement( 'test.module9', function () {} ); + assert.strictEqual( mw.loader.getState( 'test.module7' ), 'missing', 'Expected "missing" state for test.module7' ); + assert.strictEqual( mw.loader.getState( 'test.module8' ), 'error', 'Expected "error" state for test.module8' ); + assert.strictEqual( mw.loader.getState( 'test.module9' ), 'error', 'Expected "error" state for test.module9' ); + mw.loader.using( + [ 'test.module7' ], + function () { + assert.ok( false, 'Success fired despite missing dependency' ); + assert.ok( true, 'QUnit expected() count dummy' ); + }, + function ( e, dependencies ) { + assert.strictEqual( $.isArray( dependencies ), true, 'Expected array of dependencies' ); + assert.deepEqual( dependencies, [ 'test.module7' ], 'Error callback called with module test.module7' ); + } + ); + mw.loader.using( + [ 'test.module9' ], + function () { + assert.ok( false, 'Success fired despite missing dependency' ); + assert.ok( true, 'QUnit expected() count dummy' ); + }, + function ( e, dependencies ) { + assert.strictEqual( $.isArray( dependencies ), true, 'Expected array of dependencies' ); + dependencies.sort(); + assert.deepEqual( + dependencies, + [ 'test.module7', 'test.module8', 'test.module9' ], + 'Error callback called with all three modules as dependencies' + ); + } + ); + } ); + + QUnit.test( 'Dependency handling', 5, function ( assert ) { + var done = assert.async(); + mw.loader.register( [ + // [module, version, dependencies, group, source] + [ 'testMissing', '1', [], null, 'testloader' ], + [ 'testUsesMissing', '1', [ 'testMissing' ], null, 'testloader' ], + [ 'testUsesNestedMissing', '1', [ 'testUsesMissing' ], null, 'testloader' ] + ] ); + + function verifyModuleStates() { + assert.equal( mw.loader.getState( 'testMissing' ), 'missing', 'Module not known to server must have state "missing"' ); + assert.equal( mw.loader.getState( 'testUsesMissing' ), 'error', 'Module with missing dependency must have state "error"' ); + assert.equal( mw.loader.getState( 'testUsesNestedMissing' ), 'error', 'Module with indirect missing dependency must have state "error"' ); + } + + mw.loader.using( [ 'testUsesNestedMissing' ], + function () { + assert.ok( false, 'Error handler should be invoked.' ); + assert.ok( true ); // Dummy to reach QUnit expect() + + verifyModuleStates(); + + done(); + }, + function ( e, badmodules ) { + assert.ok( true, 'Error handler should be invoked.' ); + // As soon as server spits out state('testMissing', 'missing'); + // it will bubble up and trigger the error callback. + // Therefor the badmodules array is not testUsesMissing or testUsesNestedMissing. + assert.deepEqual( badmodules, [ 'testMissing' ], 'Bad modules as expected.' ); + + verifyModuleStates(); + + done(); + } + ); + } ); + + QUnit.test( 'Skip-function handling', 5, function ( assert ) { + mw.loader.register( [ + // [module, version, dependencies, group, source, skip] + [ 'testSkipped', '1', [], null, 'testloader', 'return true;' ], + [ 'testNotSkipped', '1', [], null, 'testloader', 'return false;' ], + [ 'testUsesSkippable', '1', [ 'testSkipped', 'testNotSkipped' ], null, 'testloader' ] + ] ); + + function verifyModuleStates() { + assert.equal( mw.loader.getState( 'testSkipped' ), 'ready', 'Module is ready when skipped' ); + assert.equal( mw.loader.getState( 'testNotSkipped' ), 'ready', 'Module is ready when not skipped but loaded' ); + assert.equal( mw.loader.getState( 'testUsesSkippable' ), 'ready', 'Module is ready when skippable dependencies are ready' ); + } + + return mw.loader.using( [ 'testUsesSkippable' ], + function () { + assert.ok( true, 'Success handler should be invoked.' ); + assert.ok( true ); // Dummy to match error handler and reach QUnit expect() + + verifyModuleStates(); + }, + function ( e, badmodules ) { + assert.ok( false, 'Error handler should not be invoked.' ); + assert.deepEqual( badmodules, [], 'Bad modules as expected.' ); + + verifyModuleStates(); + } + ); + } ); + + QUnit.asyncTest( '.load( "//protocol-relative" ) - T32825', 2, function ( assert ) { + // This bug was actually already fixed in 1.18 and later when discovered in 1.17. + // Test is for regressions! + + // Forge a URL to the test callback script + var target = QUnit.fixurl( + mw.config.get( 'wgServer' ) + mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/qunitOkCall.js' + ); + + // Confirm that mw.loader.load() works with protocol-relative URLs + target = target.replace( /https?:/, '' ); + + assert.equal( target.slice( 0, 2 ), '//', + 'URL must be relative to test relative URLs!' + ); + + // Async! + // The target calls QUnit.start + mw.loader.load( target ); + } ); + + QUnit.asyncTest( '.load( "/absolute-path" )', 2, function ( assert ) { + // Forge a URL to the test callback script + var target = QUnit.fixurl( + mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/qunitOkCall.js' + ); + + // Confirm that mw.loader.load() works with absolute-paths (relative to current hostname) + assert.equal( target.slice( 0, 1 ), '/', 'URL is relative to document root' ); + + // Async! + // The target calls QUnit.start + mw.loader.load( target ); + } ); + + QUnit.test( 'Empty string module name - T28804', function ( assert ) { + var done = false; + + assert.strictEqual( mw.loader.getState( '' ), null, 'State (unregistered)' ); + + mw.loader.register( '', 'v1' ); + assert.strictEqual( mw.loader.getState( '' ), 'registered', 'State (registered)' ); + assert.strictEqual( mw.loader.getVersion( '' ), 'v1', 'Version' ); + + mw.loader.implement( '', function () { + done = true; + } ); + + return mw.loader.using( '', function () { + assert.strictEqual( done, true, 'script ran' ); + assert.strictEqual( mw.loader.getState( '' ), 'ready', 'State (ready)' ); + } ); + } ); + + QUnit.test( 'Executing race - T112232', 2, function ( assert ) { + var done = false; + + // The red herring schedules its CSS buffer first. In T112232, a bug in the + // state machine would cause the job for testRaceLoadMe to run with an earlier job. + mw.loader.implement( + 'testRaceRedHerring', + function () {}, + { css: [ '.mw-testRaceRedHerring {}' ] } + ); + mw.loader.implement( + 'testRaceLoadMe', + function () { + done = true; + }, + { css: [ '.mw-testRaceLoadMe { float: left; }' ] } + ); + + mw.loader.load( [ 'testRaceRedHerring', 'testRaceLoadMe' ] ); + return mw.loader.using( 'testRaceLoadMe', function () { + assert.strictEqual( done, true, 'script ran' ); + assert.strictEqual( mw.loader.getState( 'testRaceLoadMe' ), 'ready', 'state' ); + } ); + } ); + + QUnit.test( 'Stale response caching - T117587', function ( assert ) { + var count = 0; + mw.loader.store.enabled = true; + mw.loader.register( 'test.stale', 'v2' ); + assert.strictEqual( mw.loader.store.get( 'test.stale' ), false, 'Not in store' ); + + mw.loader.implement( 'test.stale@v1', function () { + count++; + } ); + + return mw.loader.using( 'test.stale' ) + .then( function () { + assert.strictEqual( count, 1 ); + // After implementing, registry contains version as implemented by the response. + assert.strictEqual( mw.loader.getVersion( 'test.stale' ), 'v1', 'Override version' ); + assert.strictEqual( mw.loader.getState( 'test.stale' ), 'ready' ); + assert.ok( mw.loader.store.get( 'test.stale' ), 'In store' ); + } ) + .then( function () { + // Reset run time, but keep mw.loader.store + mw.loader.moduleRegistry[ 'test.stale' ].script = undefined; + mw.loader.moduleRegistry[ 'test.stale' ].state = 'registered'; + mw.loader.moduleRegistry[ 'test.stale' ].version = 'v2'; + + // Module was stored correctly as v1 + // On future navigations, it will be ignored until evicted + assert.strictEqual( mw.loader.store.get( 'test.stale' ), false, 'Not in store' ); + } ); + } ); + + QUnit.test( 'Stale response caching - backcompat', function ( assert ) { + var count = 0; + mw.loader.store.enabled = true; + mw.loader.register( 'test.stalebc', 'v2' ); + assert.strictEqual( mw.loader.store.get( 'test.stalebc' ), false, 'Not in store' ); + + mw.loader.implement( 'test.stalebc', function () { + count++; + } ); + + return mw.loader.using( 'test.stalebc' ) + .then( function () { + assert.strictEqual( count, 1 ); + assert.strictEqual( mw.loader.getState( 'test.stalebc' ), 'ready' ); + assert.ok( mw.loader.store.get( 'test.stalebc' ), 'In store' ); + } ) + .then( function () { + // Reset run time, but keep mw.loader.store + mw.loader.moduleRegistry[ 'test.stalebc' ].script = undefined; + mw.loader.moduleRegistry[ 'test.stalebc' ].state = 'registered'; + mw.loader.moduleRegistry[ 'test.stalebc' ].version = 'v2'; + + // Legacy behaviour is storing under the expected version, + // which woudl lead to whitewashing and stale values (T117587). + assert.ok( mw.loader.store.get( 'test.stalebc' ), 'In store' ); + } ); + } ); + + QUnit.test( 'require()', 6, function ( assert ) { + mw.loader.register( [ + [ 'test.require1', '0' ], + [ 'test.require2', '0' ], + [ 'test.require3', '0' ], + [ 'test.require4', '0', [ 'test.require3' ] ] + ] ); + mw.loader.implement( 'test.require1', function () {} ); + mw.loader.implement( 'test.require2', function ( $, jQuery, require, module ) { + module.exports = 1; + } ); + mw.loader.implement( 'test.require3', function ( $, jQuery, require, module ) { + module.exports = function () { + return 'hello world'; + }; + } ); + mw.loader.implement( 'test.require4', function ( $, jQuery, require, module ) { + var other = require( 'test.require3' ); + module.exports = { + pizza: function () { + return other(); + } + }; + } ); + return mw.loader.using( [ 'test.require1', 'test.require2', 'test.require3', 'test.require4' ] ) + .then( function ( require ) { + var module1, module2, module3, module4; + + module1 = require( 'test.require1' ); + module2 = require( 'test.require2' ); + module3 = require( 'test.require3' ); + module4 = require( 'test.require4' ); + + assert.strictEqual( typeof module1, 'object', 'export of module with no export' ); + assert.strictEqual( module2, 1, 'export a number' ); + assert.strictEqual( module3(), 'hello world', 'export a function' ); + assert.strictEqual( typeof module4.pizza, 'function', 'export an object' ); + assert.strictEqual( module4.pizza(), 'hello world', 'module can require other modules' ); + + assert.throws( function () { + require( '_badmodule' ); + }, /is not loaded/, 'Requesting non-existent modules throws error.' ); + } ); + } ); + + QUnit.test( 'require() in debug mode', function ( assert ) { + var path = mw.config.get( 'wgScriptPath' ); + mw.loader.register( [ + [ 'test.require.define', '0' ], + [ 'test.require.callback', '0', [ 'test.require.define' ] ] + ] ); + mw.loader.implement( 'test.require.callback', [ QUnit.fixurl( path + '/tests/qunit/data/requireCallMwLoaderTestCallback.js' ) ] ); + mw.loader.implement( 'test.require.define', [ QUnit.fixurl( path + '/tests/qunit/data/defineCallMwLoaderTestCallback.js' ) ] ); + + return mw.loader.using( 'test.require.callback' ).then( function ( require ) { + var cb = require( 'test.require.callback' ); + assert.strictEqual( cb.immediate, 'Defined.', 'module.exports and require work in debug mode' ); + // Must use try-catch because cb.later() will throw if require is undefined, + // which doesn't work well inside Deferred.then() when using jQuery 1.x with QUnit + try { + assert.strictEqual( cb.later(), 'Defined.', 'require works asynchrously in debug mode' ); + } catch ( e ) { + assert.equal( null, String( e ), 'require works asynchrously in debug mode' ); + } + }, function () { + assert.ok( false, 'Error callback fired while loader.using "test.require.callback" module' ); + } ); + } ); + +}( mediaWiki, jQuery ) ); diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.requestIdleCallback.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.requestIdleCallback.test.js index 7a0996496a..df02693bff 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.requestIdleCallback.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.requestIdleCallback.test.js @@ -95,8 +95,9 @@ if ( window.requestIdleCallback ) { QUnit.test( 'native', function ( assert ) { var done = assert.async(); - // Remove polyfill + // Remove polyfill and clock stub mw.requestIdleCallback.restore(); + this.clock.restore(); mw.requestIdleCallback( function () { assert.expect( 0 ); done(); diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.storage.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.storage.test.js index 6cef4a7c81..436cb2ed75 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.storage.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.storage.test.js @@ -1,36 +1,56 @@ ( function ( mw ) { QUnit.module( 'mediawiki.storage' ); - QUnit.test( 'set/get with localStorage', 3, function ( assert ) { - this.sandbox.stub( mw.storage, 'localStorage', { + QUnit.test( 'set/get with storage support', function ( assert ) { + var stub = { setItem: this.sandbox.spy(), getItem: this.sandbox.stub() - } ); + }; + stub.getItem.withArgs( 'foo' ).returns( 'test' ); + stub.getItem.returns( null ); + this.sandbox.stub( mw.storage, 'store', stub ); mw.storage.set( 'foo', 'test' ); - assert.ok( mw.storage.localStorage.setItem.calledOnce ); + assert.ok( stub.setItem.calledOnce ); - mw.storage.localStorage.getItem.withArgs( 'foo' ).returns( 'test' ); - mw.storage.localStorage.getItem.returns( null ); assert.strictEqual( mw.storage.get( 'foo' ), 'test', 'Check value gets stored.' ); assert.strictEqual( mw.storage.get( 'bar' ), null, 'Unset values are null.' ); } ); - QUnit.test( 'set/get without localStorage', 3, function ( assert ) { - this.sandbox.stub( mw.storage, 'localStorage', { + QUnit.test( 'set/get with storage methods disabled', function ( assert ) { + // This covers browsers where storage is disabled + // (quota full, or security/privacy settings). + // On most browsers, these interface will be accessible with + // their methods throwing. + var stub = { getItem: this.sandbox.stub(), removeItem: this.sandbox.stub(), setItem: this.sandbox.stub() - } ); + }; + stub.getItem.throws(); + stub.setItem.throws(); + stub.removeItem.throws(); + this.sandbox.stub( mw.storage, 'store', stub ); - mw.storage.localStorage.getItem.throws(); assert.strictEqual( mw.storage.get( 'foo' ), false ); - - mw.storage.localStorage.setItem.throws(); assert.strictEqual( mw.storage.set( 'foo', 'test' ), false ); + assert.strictEqual( mw.storage.remove( 'foo', 'test' ), false ); + } ); + + QUnit.test( 'set/get with storage object disabled', function ( assert ) { + // On other browsers, these entire object is disabled. + // `'localStorage' in window` would be true (and pass feature test) + // but trying to read the object as window.localStorage would throw + // an exception. Such case would instantiate SafeStorage with + // undefined after the internal try/catch. + var old = mw.storage.store; + mw.storage.store = undefined; - mw.storage.localStorage.removeItem.throws(); + assert.strictEqual( mw.storage.get( 'foo' ), false ); + assert.strictEqual( mw.storage.set( 'foo', 'test' ), false ); assert.strictEqual( mw.storage.remove( 'foo', 'test' ), false ); + + mw.storage.store = old; } ); }( mediaWiki ) ); diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.test.js index dd43c553bc..bac8274fef 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.test.js @@ -1,5 +1,4 @@ -/*jshint -W024 */ -( function ( mw, $ ) { +( function ( mw ) { var specialCharactersPageName, // Can't mock SITENAME since jqueryMsg caches it at load siteName = mw.config.get( 'wgSiteName' ); @@ -32,11 +31,6 @@ } } ) ); - mw.loader.addSource( - 'testloader', - QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/load.mock.php' ) - ); - QUnit.test( 'Initial check', 8, function ( assert ) { assert.ok( window.jQuery, 'jQuery defined' ); assert.ok( window.$, '$ defined' ); @@ -70,10 +64,11 @@ ); } ); - QUnit.test( 'mw.Map', 35, function ( assert ) { + QUnit.test( 'mw.Map', function ( assert ) { var arry, conf, funky, globalConf, nummy, someValues; conf = new mw.Map(); + // Dummy variables funky = function () {}; arry = []; @@ -131,15 +126,15 @@ lorem: 'ipsum' }, 'Map.get returns multiple values correctly as an object' ); - assert.deepEqual( conf, new mw.Map( conf.values ), 'new mw.Map maps over existing values-bearing object' ); - assert.deepEqual( conf.get( [ 'foo', 'notExist' ] ), { foo: 'bar', notExist: null }, 'Map.get return includes keys that were not found as null values' ); // Interacting with globals and accessing the values object + this.suppressWarnings(); assert.strictEqual( conf.get(), conf.values, 'Map.get returns the entire values object by reference (if called without arguments)' ); + this.restoreWarnings(); conf.set( 'globalMapChecker', 'Hi' ); @@ -175,11 +170,7 @@ } } ); - QUnit.test( 'mw.config', 1, function ( assert ) { - assert.ok( mw.config instanceof mw.Map, 'mw.config instance of mw.Map' ); - } ); - - QUnit.test( 'mw.message & mw.messages', 100, function ( assert ) { + QUnit.test( 'mw.message & mw.messages', function ( assert ) { var goodbye, hello; // Convenience method for asserting the same result for multiple formats @@ -194,7 +185,6 @@ } assert.ok( mw.messages, 'messages defined' ); - assert.ok( mw.messages instanceof mw.Map, 'mw.messages instance of mw.Map' ); assert.ok( mw.messages.set( 'hello', 'Hello <b>awesome</b> world' ), 'mw.messages.set: Register' ); hello = mw.message( 'hello' ); @@ -248,9 +238,7 @@ goodbye = mw.message( 'goodbye' ); assert.strictEqual( goodbye.exists(), false, 'Message.exists returns false for nonexistent messages' ); - assertMultipleFormats( [ 'goodbye' ], [ 'plain', 'text' ], '<goodbye>', 'Message.toString returns <key> if key does not exist' ); - // bug 30684 - assertMultipleFormats( [ 'goodbye' ], [ 'parse', 'escaped' ], '&lt;goodbye&gt;', 'Message.toString returns properly escaped &lt;key&gt; if key does not exist' ); + assertMultipleFormats( [ 'good<>bye' ], [ 'plain', 'text', 'parse', 'escaped' ], '⧼good&lt;&gt;bye⧽', 'Message.toString returns ⧼key⧽ if key does not exist' ); assert.ok( mw.messages.set( 'plural-test-msg', 'There {{PLURAL:$1|is|are}} $1 {{PLURAL:$1|result|results}}' ), 'mw.messages.set: Register' ); assertMultipleFormats( [ 'plural-test-msg', 6 ], [ 'text', 'parse', 'escaped' ], 'There are 6 results', 'plural get resolved' ); @@ -329,7 +317,7 @@ QUnit.test( 'mw.msg', 14, function ( assert ) { assert.ok( mw.messages.set( 'hello', 'Hello <b>awesome</b> world' ), 'mw.messages.set: Register' ); assert.equal( mw.msg( 'hello' ), 'Hello <b>awesome</b> world', 'Gets message with default options (existing message)' ); - assert.equal( mw.msg( 'goodbye' ), '<goodbye>', 'Gets message with default options (nonexistent message)' ); + assert.equal( mw.msg( 'goodbye' ), '⧼goodbye⧽', 'Gets message with default options (nonexistent message)' ); assert.ok( mw.messages.set( 'plural-item', 'Found $1 {{PLURAL:$1|item|items}}' ), 'mw.messages.set: Register' ); assert.equal( mw.msg( 'plural-item', 5 ), 'Found 5 items', 'Apply plural for count 5' ); @@ -349,650 +337,6 @@ assert.equal( mw.msg( 'int-msg' ), 'Some Other Message', 'int is resolved' ); } ); - /** - * The sync style load test (for @import). This is, in a way, also an open bug for - * ResourceLoader ("execute js after styles are loaded"), but browsers don't offer a - * way to get a callback from when a stylesheet is loaded (that is, including any - * `@import` rules inside). To work around this, we'll have a little time loop to check - * if the styles apply. - * - * Note: This test originally used new Image() and onerror to get a callback - * when the url is loaded, but that is fragile since it doesn't monitor the - * same request as the css @import, and Safari 4 has issues with - * onerror/onload not being fired at all in weird cases like this. - */ - function assertStyleAsync( assert, $element, prop, val, fn ) { - var styleTestStart, - el = $element.get( 0 ), - styleTestTimeout = ( QUnit.config.testTimeout || 5000 ) - 200; - - function isCssImportApplied() { - // Trigger reflow, repaint, redraw, whatever (cross-browser) - var x = $element.css( 'height' ); - x = el.innerHTML; - el.className = el.className; - x = document.documentElement.clientHeight; - - return $element.css( prop ) === val; - } - - function styleTestLoop() { - var styleTestSince = new Date().getTime() - styleTestStart; - // If it is passing or if we timed out, run the real test and stop the loop - if ( isCssImportApplied() || styleTestSince > styleTestTimeout ) { - assert.equal( $element.css( prop ), val, - 'style "' + prop + ': ' + val + '" from url is applied (after ' + styleTestSince + 'ms)' - ); - - if ( fn ) { - fn(); - } - - return; - } - // Otherwise, keep polling - setTimeout( styleTestLoop ); - } - - // Start the loop - styleTestStart = new Date().getTime(); - styleTestLoop(); - } - - function urlStyleTest( selector, prop, val ) { - return QUnit.fixurl( - mw.config.get( 'wgScriptPath' ) + - '/tests/qunit/data/styleTest.css.php?' + - $.param( { - selector: selector, - prop: prop, - val: val - } ) - ); - } - - QUnit.asyncTest( 'mw.loader', 2, function ( assert ) { - var isAwesomeDone; - - mw.loader.testCallback = function () { - QUnit.start(); - assert.strictEqual( isAwesomeDone, undefined, 'Implementing module is.awesome: isAwesomeDone should still be undefined' ); - isAwesomeDone = true; - }; - - mw.loader.implement( 'test.callback', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/callMwLoaderTestCallback.js' ) ] ); - - mw.loader.using( 'test.callback', function () { - - // /sample/awesome.js declares the "mw.loader.testCallback" function - // which contains a call to start() and ok() - assert.strictEqual( isAwesomeDone, true, 'test.callback module should\'ve caused isAwesomeDone to be true' ); - delete mw.loader.testCallback; - - }, function () { - QUnit.start(); - assert.ok( false, 'Error callback fired while loader.using "test.callback" module' ); - } ); - } ); - - QUnit.asyncTest( 'mw.loader with Object method as module name', 2, function ( assert ) { - var isAwesomeDone; - - mw.loader.testCallback = function () { - QUnit.start(); - assert.strictEqual( isAwesomeDone, undefined, 'Implementing module hasOwnProperty: isAwesomeDone should still be undefined' ); - isAwesomeDone = true; - }; - - mw.loader.implement( 'hasOwnProperty', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/callMwLoaderTestCallback.js' ) ], {}, {} ); - - mw.loader.using( 'hasOwnProperty', function () { - - // /sample/awesome.js declares the "mw.loader.testCallback" function - // which contains a call to start() and ok() - assert.strictEqual( isAwesomeDone, true, 'hasOwnProperty module should\'ve caused isAwesomeDone to be true' ); - delete mw.loader.testCallback; - - }, function () { - QUnit.start(); - assert.ok( false, 'Error callback fired while loader.using "hasOwnProperty" module' ); - } ); - } ); - - QUnit.asyncTest( 'mw.loader.using( .. ) Promise', 2, function ( assert ) { - var isAwesomeDone; - - mw.loader.testCallback = function () { - QUnit.start(); - assert.strictEqual( isAwesomeDone, undefined, 'Implementing module is.awesome: isAwesomeDone should still be undefined' ); - isAwesomeDone = true; - }; - - mw.loader.implement( 'test.promise', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/callMwLoaderTestCallback.js' ) ] ); - - mw.loader.using( 'test.promise' ) - .done( function () { - - // /sample/awesome.js declares the "mw.loader.testCallback" function - // which contains a call to start() and ok() - assert.strictEqual( isAwesomeDone, true, 'test.promise module should\'ve caused isAwesomeDone to be true' ); - delete mw.loader.testCallback; - - } ) - .fail( function () { - QUnit.start(); - assert.ok( false, 'Error callback fired while loader.using "test.promise" module' ); - } ); - } ); - - QUnit.asyncTest( 'mw.loader.implement( styles={ "css": [text, ..] } )', 2, function ( assert ) { - var $element = $( '<div class="mw-test-implement-a"></div>' ).appendTo( '#qunit-fixture' ); - - assert.notEqual( - $element.css( 'float' ), - 'right', - 'style is clear' - ); - - mw.loader.implement( - 'test.implement.a', - function () { - assert.equal( - $element.css( 'float' ), - 'right', - 'style is applied' - ); - QUnit.start(); - }, - { - all: '.mw-test-implement-a { float: right; }' - } - ); - - mw.loader.load( [ - 'test.implement.a' - ] ); - } ); - - QUnit.asyncTest( 'mw.loader.implement( styles={ "url": { <media>: [url, ..] } } )', 7, function ( assert ) { - var $element1 = $( '<div class="mw-test-implement-b1"></div>' ).appendTo( '#qunit-fixture' ), - $element2 = $( '<div class="mw-test-implement-b2"></div>' ).appendTo( '#qunit-fixture' ), - $element3 = $( '<div class="mw-test-implement-b3"></div>' ).appendTo( '#qunit-fixture' ); - - assert.notEqual( - $element1.css( 'text-align' ), - 'center', - 'style is clear' - ); - assert.notEqual( - $element2.css( 'float' ), - 'left', - 'style is clear' - ); - assert.notEqual( - $element3.css( 'text-align' ), - 'right', - 'style is clear' - ); - - mw.loader.implement( - 'test.implement.b', - function () { - // Note: QUnit.start() must only be called when the entire test is - // complete. So, make sure that we don't start until *both* - // assertStyleAsync calls have completed. - var pending = 2; - assertStyleAsync( assert, $element2, 'float', 'left', function () { - assert.notEqual( $element1.css( 'text-align' ), 'center', 'print style is not applied' ); - - pending--; - if ( pending === 0 ) { - QUnit.start(); - } - } ); - assertStyleAsync( assert, $element3, 'float', 'right', function () { - assert.notEqual( $element1.css( 'text-align' ), 'center', 'print style is not applied' ); - - pending--; - if ( pending === 0 ) { - QUnit.start(); - } - } ); - }, - { - url: { - print: [ urlStyleTest( '.mw-test-implement-b1', 'text-align', 'center' ) ], - screen: [ - // bug 40834: Make sure it actually works with more than 1 stylesheet reference - urlStyleTest( '.mw-test-implement-b2', 'float', 'left' ), - urlStyleTest( '.mw-test-implement-b3', 'float', 'right' ) - ] - } - } - ); - - mw.loader.load( [ - 'test.implement.b' - ] ); - } ); - - // Backwards compatibility - QUnit.asyncTest( 'mw.loader.implement( styles={ <media>: text } ) (back-compat)', 2, function ( assert ) { - var $element = $( '<div class="mw-test-implement-c"></div>' ).appendTo( '#qunit-fixture' ); - - assert.notEqual( - $element.css( 'float' ), - 'right', - 'style is clear' - ); - - mw.loader.implement( - 'test.implement.c', - function () { - assert.equal( - $element.css( 'float' ), - 'right', - 'style is applied' - ); - QUnit.start(); - }, - { - all: '.mw-test-implement-c { float: right; }' - } - ); - - mw.loader.load( [ - 'test.implement.c' - ] ); - } ); - - // Backwards compatibility - QUnit.asyncTest( 'mw.loader.implement( styles={ <media>: [url, ..] } ) (back-compat)', 4, function ( assert ) { - var $element = $( '<div class="mw-test-implement-d"></div>' ).appendTo( '#qunit-fixture' ), - $element2 = $( '<div class="mw-test-implement-d2"></div>' ).appendTo( '#qunit-fixture' ); - - assert.notEqual( - $element.css( 'float' ), - 'right', - 'style is clear' - ); - assert.notEqual( - $element2.css( 'text-align' ), - 'center', - 'style is clear' - ); - - mw.loader.implement( - 'test.implement.d', - function () { - assertStyleAsync( assert, $element, 'float', 'right', function () { - - assert.notEqual( $element2.css( 'text-align' ), 'center', 'print style is not applied (bug 40500)' ); - - QUnit.start(); - } ); - }, - { - all: [ urlStyleTest( '.mw-test-implement-d', 'float', 'right' ) ], - print: [ urlStyleTest( '.mw-test-implement-d2', 'text-align', 'center' ) ] - } - ); - - mw.loader.load( [ - 'test.implement.d' - ] ); - } ); - - // @import (bug 31676) - QUnit.asyncTest( 'mw.loader.implement( styles has @import )', 7, function ( assert ) { - var isJsExecuted, $element; - - mw.loader.implement( - 'test.implement.import', - function () { - assert.strictEqual( isJsExecuted, undefined, 'script not executed multiple times' ); - isJsExecuted = true; - - assert.equal( mw.loader.getState( 'test.implement.import' ), 'executing', 'module state during implement() script execution' ); - - $element = $( '<div class="mw-test-implement-import">Foo bar</div>' ).appendTo( '#qunit-fixture' ); - - assert.equal( mw.msg( 'test-foobar' ), 'Hello Foobar, $1!', 'messages load before script execution' ); - - assertStyleAsync( assert, $element, 'float', 'right', function () { - assert.equal( $element.css( 'text-align' ), 'center', - 'CSS styles after the @import rule are working' - ); - - QUnit.start(); - } ); - }, - { - css: [ - '@import url(\'' - + urlStyleTest( '.mw-test-implement-import', 'float', 'right' ) - + '\');\n' - + '.mw-test-implement-import { text-align: center; }' - ] - }, - { - 'test-foobar': 'Hello Foobar, $1!' - } - ); - - mw.loader.using( 'test.implement.import' ).always( function () { - assert.strictEqual( isJsExecuted, true, 'script executed' ); - assert.equal( mw.loader.getState( 'test.implement.import' ), 'ready', 'module state after script execution' ); - } ); - } ); - - QUnit.asyncTest( 'mw.loader.implement( dependency with styles )', 4, function ( assert ) { - var $element = $( '<div class="mw-test-implement-e"></div>' ).appendTo( '#qunit-fixture' ), - $element2 = $( '<div class="mw-test-implement-e2"></div>' ).appendTo( '#qunit-fixture' ); - - assert.notEqual( - $element.css( 'float' ), - 'right', - 'style is clear' - ); - assert.notEqual( - $element2.css( 'float' ), - 'left', - 'style is clear' - ); - - mw.loader.register( [ - [ 'test.implement.e', '0', [ 'test.implement.e2' ] ], - [ 'test.implement.e2', '0' ] - ] ); - - mw.loader.implement( - 'test.implement.e', - function () { - assert.equal( - $element.css( 'float' ), - 'right', - 'Depending module\'s style is applied' - ); - QUnit.start(); - }, - { - all: '.mw-test-implement-e { float: right; }' - } - ); - - mw.loader.implement( - 'test.implement.e2', - function () { - assert.equal( - $element2.css( 'float' ), - 'left', - 'Dependency\'s style is applied' - ); - }, - { - all: '.mw-test-implement-e2 { float: left; }' - } - ); - - mw.loader.load( [ - 'test.implement.e' - ] ); - } ); - - QUnit.test( 'mw.loader.implement( only scripts )', 1, function ( assert ) { - mw.loader.implement( 'test.onlyscripts', function () {} ); - assert.strictEqual( mw.loader.getState( 'test.onlyscripts' ), 'ready' ); - } ); - - QUnit.asyncTest( 'mw.loader.implement( only messages )', 2, function ( assert ) { - assert.assertFalse( mw.messages.exists( 'bug_29107' ), 'Verify that the test message doesn\'t exist yet' ); - - // jscs: disable requireCamelCaseOrUpperCaseIdentifiers - mw.loader.implement( 'test.implement.msgs', [], {}, { bug_29107: 'loaded' } ); - // jscs: enable requireCamelCaseOrUpperCaseIdentifiers - mw.loader.using( 'test.implement.msgs', function () { - QUnit.start(); - assert.ok( mw.messages.exists( 'bug_29107' ), 'Bug 29107: messages-only module should implement ok' ); - }, function () { - QUnit.start(); - assert.ok( false, 'Error callback fired while implementing "test.implement.msgs" module' ); - } ); - } ); - - QUnit.test( 'mw.loader.implement( empty )', 1, function ( assert ) { - mw.loader.implement( 'test.empty' ); - assert.strictEqual( mw.loader.getState( 'test.empty' ), 'ready' ); - } ); - - QUnit.test( 'mw.loader with broken indirect dependency', 4, function ( assert ) { - // don't emit an error event - this.sandbox.stub( mw, 'track' ); - - mw.loader.register( [ - [ 'test.module1', '0' ], - [ 'test.module2', '0', [ 'test.module1' ] ], - [ 'test.module3', '0', [ 'test.module2' ] ] - ] ); - mw.loader.implement( 'test.module1', function () { - throw new Error( 'expected' ); - }, {}, {} ); - assert.strictEqual( mw.loader.getState( 'test.module1' ), 'error', 'Expected "error" state for test.module1' ); - assert.strictEqual( mw.loader.getState( 'test.module2' ), 'error', 'Expected "error" state for test.module2' ); - assert.strictEqual( mw.loader.getState( 'test.module3' ), 'error', 'Expected "error" state for test.module3' ); - - assert.strictEqual( mw.track.callCount, 1 ); - } ); - - QUnit.test( 'mw.loader with circular dependency', 1, function ( assert ) { - mw.loader.register( [ - [ 'test.circle1', '0', [ 'test.circle2' ] ], - [ 'test.circle2', '0', [ 'test.circle3' ] ], - [ 'test.circle3', '0', [ 'test.circle1' ] ] - ] ); - assert.throws( function () { - mw.loader.using( 'test.circle3' ); - }, /Circular/, 'Detect circular dependency' ); - } ); - - QUnit.test( 'mw.loader out-of-order implementation', 9, function ( assert ) { - mw.loader.register( [ - [ 'test.module4', '0' ], - [ 'test.module5', '0', [ 'test.module4' ] ], - [ 'test.module6', '0', [ 'test.module5' ] ] - ] ); - mw.loader.implement( 'test.module4', function () {} ); - assert.strictEqual( mw.loader.getState( 'test.module4' ), 'ready', 'Expected "ready" state for test.module4' ); - assert.strictEqual( mw.loader.getState( 'test.module5' ), 'registered', 'Expected "registered" state for test.module5' ); - assert.strictEqual( mw.loader.getState( 'test.module6' ), 'registered', 'Expected "registered" state for test.module6' ); - mw.loader.implement( 'test.module6', function () {} ); - assert.strictEqual( mw.loader.getState( 'test.module4' ), 'ready', 'Expected "ready" state for test.module4' ); - assert.strictEqual( mw.loader.getState( 'test.module5' ), 'registered', 'Expected "registered" state for test.module5' ); - assert.strictEqual( mw.loader.getState( 'test.module6' ), 'loaded', 'Expected "loaded" state for test.module6' ); - mw.loader.implement( 'test.module5', function () {} ); - assert.strictEqual( mw.loader.getState( 'test.module4' ), 'ready', 'Expected "ready" state for test.module4' ); - assert.strictEqual( mw.loader.getState( 'test.module5' ), 'ready', 'Expected "ready" state for test.module5' ); - assert.strictEqual( mw.loader.getState( 'test.module6' ), 'ready', 'Expected "ready" state for test.module6' ); - } ); - - QUnit.test( 'mw.loader missing dependency', 13, function ( assert ) { - mw.loader.register( [ - [ 'test.module7', '0' ], - [ 'test.module8', '0', [ 'test.module7' ] ], - [ 'test.module9', '0', [ 'test.module8' ] ] - ] ); - mw.loader.implement( 'test.module8', function () {} ); - assert.strictEqual( mw.loader.getState( 'test.module7' ), 'registered', 'Expected "registered" state for test.module7' ); - assert.strictEqual( mw.loader.getState( 'test.module8' ), 'loaded', 'Expected "loaded" state for test.module8' ); - assert.strictEqual( mw.loader.getState( 'test.module9' ), 'registered', 'Expected "registered" state for test.module9' ); - mw.loader.state( 'test.module7', 'missing' ); - assert.strictEqual( mw.loader.getState( 'test.module7' ), 'missing', 'Expected "missing" state for test.module7' ); - assert.strictEqual( mw.loader.getState( 'test.module8' ), 'error', 'Expected "error" state for test.module8' ); - assert.strictEqual( mw.loader.getState( 'test.module9' ), 'error', 'Expected "error" state for test.module9' ); - mw.loader.implement( 'test.module9', function () {} ); - assert.strictEqual( mw.loader.getState( 'test.module7' ), 'missing', 'Expected "missing" state for test.module7' ); - assert.strictEqual( mw.loader.getState( 'test.module8' ), 'error', 'Expected "error" state for test.module8' ); - assert.strictEqual( mw.loader.getState( 'test.module9' ), 'error', 'Expected "error" state for test.module9' ); - mw.loader.using( - [ 'test.module7' ], - function () { - assert.ok( false, 'Success fired despite missing dependency' ); - assert.ok( true, 'QUnit expected() count dummy' ); - }, - function ( e, dependencies ) { - assert.strictEqual( $.isArray( dependencies ), true, 'Expected array of dependencies' ); - assert.deepEqual( dependencies, [ 'test.module7' ], 'Error callback called with module test.module7' ); - } - ); - mw.loader.using( - [ 'test.module9' ], - function () { - assert.ok( false, 'Success fired despite missing dependency' ); - assert.ok( true, 'QUnit expected() count dummy' ); - }, - function ( e, dependencies ) { - assert.strictEqual( $.isArray( dependencies ), true, 'Expected array of dependencies' ); - dependencies.sort(); - assert.deepEqual( - dependencies, - [ 'test.module7', 'test.module8', 'test.module9' ], - 'Error callback called with all three modules as dependencies' - ); - } - ); - } ); - - QUnit.asyncTest( 'mw.loader dependency handling', 5, function ( assert ) { - mw.loader.register( [ - // [module, version, dependencies, group, source] - [ 'testMissing', '1', [], null, 'testloader' ], - [ 'testUsesMissing', '1', [ 'testMissing' ], null, 'testloader' ], - [ 'testUsesNestedMissing', '1', [ 'testUsesMissing' ], null, 'testloader' ] - ] ); - - function verifyModuleStates() { - assert.equal( mw.loader.getState( 'testMissing' ), 'missing', 'Module not known to server must have state "missing"' ); - assert.equal( mw.loader.getState( 'testUsesMissing' ), 'error', 'Module with missing dependency must have state "error"' ); - assert.equal( mw.loader.getState( 'testUsesNestedMissing' ), 'error', 'Module with indirect missing dependency must have state "error"' ); - } - - mw.loader.using( [ 'testUsesNestedMissing' ], - function () { - assert.ok( false, 'Error handler should be invoked.' ); - assert.ok( true ); // Dummy to reach QUnit expect() - - verifyModuleStates(); - - QUnit.start(); - }, - function ( e, badmodules ) { - assert.ok( true, 'Error handler should be invoked.' ); - // As soon as server spits out state('testMissing', 'missing'); - // it will bubble up and trigger the error callback. - // Therefor the badmodules array is not testUsesMissing or testUsesNestedMissing. - assert.deepEqual( badmodules, [ 'testMissing' ], 'Bad modules as expected.' ); - - verifyModuleStates(); - - QUnit.start(); - } - ); - } ); - - QUnit.asyncTest( 'mw.loader skin-function handling', 5, function ( assert ) { - mw.loader.register( [ - // [module, version, dependencies, group, source, skip] - [ 'testSkipped', '1', [], null, 'testloader', 'return true;' ], - [ 'testNotSkipped', '1', [], null, 'testloader', 'return false;' ], - [ 'testUsesSkippable', '1', [ 'testSkipped', 'testNotSkipped' ], null, 'testloader' ] - ] ); - - function verifyModuleStates() { - assert.equal( mw.loader.getState( 'testSkipped' ), 'ready', 'Module is ready when skipped' ); - assert.equal( mw.loader.getState( 'testNotSkipped' ), 'ready', 'Module is ready when not skipped but loaded' ); - assert.equal( mw.loader.getState( 'testUsesSkippable' ), 'ready', 'Module is ready when skippable dependencies are ready' ); - } - - mw.loader.using( [ 'testUsesSkippable' ], - function () { - assert.ok( true, 'Success handler should be invoked.' ); - assert.ok( true ); // Dummy to match error handler and reach QUnit expect() - - verifyModuleStates(); - - QUnit.start(); - }, - function ( e, badmodules ) { - assert.ok( false, 'Error handler should not be invoked.' ); - assert.deepEqual( badmodules, [], 'Bad modules as expected.' ); - - verifyModuleStates(); - - QUnit.start(); - } - ); - } ); - - QUnit.asyncTest( 'mw.loader( "//protocol-relative" ) (bug 30825)', 2, function ( assert ) { - // This bug was actually already fixed in 1.18 and later when discovered in 1.17. - // Test is for regressions! - - // Forge a URL to the test callback script - var target = QUnit.fixurl( - mw.config.get( 'wgServer' ) + mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/qunitOkCall.js' - ); - - // Confirm that mw.loader.load() works with protocol-relative URLs - target = target.replace( /https?:/, '' ); - - assert.equal( target.slice( 0, 2 ), '//', - 'URL must be relative to test relative URLs!' - ); - - // Async! - // The target calls QUnit.start - mw.loader.load( target ); - } ); - - QUnit.asyncTest( 'mw.loader( "/absolute-path" )', 2, function ( assert ) { - // Forge a URL to the test callback script - var target = QUnit.fixurl( - mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/qunitOkCall.js' - ); - - // Confirm that mw.loader.load() works with absolute-paths (relative to current hostname) - assert.equal( target.slice( 0, 1 ), '/', 'URL is relative to document root' ); - - // Async! - // The target calls QUnit.start - mw.loader.load( target ); - } ); - - QUnit.asyncTest( 'mw.loader() executing race (T112232)', 2, function ( assert ) { - var done = false; - - // The red herring schedules its CSS buffer first. In T112232, a bug in the - // state machine would cause the job for testRaceLoadMe to run with an earlier job. - mw.loader.implement( - 'testRaceRedHerring', - function () {}, - { css: [ '.mw-testRaceRedHerring {}' ] } - ); - mw.loader.implement( - 'testRaceLoadMe', - function () { - done = true; - }, - { css: [ '.mw-testRaceLoadMe { float: left; }' ] } - ); - - mw.loader.load( [ 'testRaceRedHerring', 'testRaceLoadMe' ] ); - mw.loader.using( 'testRaceLoadMe', function () { - assert.strictEqual( done, true, 'script ran' ); - assert.strictEqual( mw.loader.getState( 'testRaceLoadMe' ), 'ready', 'state' ); - } ).always( QUnit.start ); - } ); - QUnit.test( 'mw.hook', 13, function ( assert ) { var hook, add, fire, chars, callback; @@ -1085,66 +429,4 @@ ); } ); - QUnit.test( 'mw.loader.require', 6, function ( assert ) { - var module1, module2, module3, module4; - - mw.loader.register( [ - [ 'test.module.require1', '0' ], - [ 'test.module.require2', '0' ], - [ 'test.module.require3', '0' ], - [ 'test.module.require4', '0', [ 'test.module.require3' ] ] - ] ); - mw.loader.implement( 'test.module.require1', function () {} ); - mw.loader.implement( 'test.module.require2', function ( $, jQuery, require, module ) { - module.exports = 1; - } ); - mw.loader.implement( 'test.module.require3', function ( $, jQuery, require, module ) { - module.exports = function () { - return 'hello world'; - }; - } ); - mw.loader.implement( 'test.module.require4', function ( $, jQuery, require, module ) { - var other = require( 'test.module.require3' ); - module.exports = { - pizza: function () { - return other(); - } - }; - } ); - module1 = mw.loader.require( 'test.module.require1' ); - module2 = mw.loader.require( 'test.module.require2' ); - module3 = mw.loader.require( 'test.module.require3' ); - module4 = mw.loader.require( 'test.module.require4' ); - - assert.strictEqual( typeof module1, 'object', 'export of module with no export' ); - assert.strictEqual( module2, 1, 'export a number' ); - assert.strictEqual( module3(), 'hello world', 'export a function' ); - assert.strictEqual( typeof module4.pizza, 'function', 'export an object' ); - assert.strictEqual( module4.pizza(), 'hello world', 'module can require other modules' ); - - assert.throws( function () { - mw.loader.require( '_badmodule' ); - }, /is not loaded/, 'Requesting non-existent modules throws error.' ); - } ); - - QUnit.asyncTest( 'mw.loader require in debug mode', 1, function ( assert ) { - var path = mw.config.get( 'wgScriptPath' ); - mw.loader.register( [ - [ 'test.require.define', '0' ], - [ 'test.require.callback', '0', [ 'test.require.define' ] ] - ] ); - mw.loader.implement( 'test.require.callback', [ QUnit.fixurl( path + '/tests/qunit/data/requireCallMwLoaderTestCallback.js' ) ] ); - mw.loader.implement( 'test.require.define', [ QUnit.fixurl( path + '/tests/qunit/data/defineCallMwLoaderTestCallback.js' ) ] ); - - mw.loader.using( 'test.require.callback', function () { - QUnit.start(); - var exported = mw.loader.require( 'test.require.callback' ); - assert.strictEqual( exported, 'Require worked.Define worked.', - 'module.exports worked in debug mode' ); - }, function () { - QUnit.start(); - assert.ok( false, 'Error callback fired while loader.using "test.require.callback" module' ); - } ); - } ); - -}( mediaWiki, jQuery ) ); +}( mediaWiki ) ); diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.user.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.user.test.js index 3332c08dc0..7f6efa0c77 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.user.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.user.test.js @@ -15,32 +15,32 @@ } } ) ); - QUnit.test( 'options', 1, function ( assert ) { + QUnit.test( 'options', function ( assert ) { assert.ok( mw.user.options instanceof mw.Map, 'options instance of mw.Map' ); } ); - QUnit.test( 'user status', 7, function ( assert ) { - + QUnit.test( 'getters (anonymous)', function ( assert ) { // Forge an anonymous user mw.config.set( 'wgUserName', null ); - delete mw.config.values.wgUserId; + mw.config.set( 'wgUserId', null ); - assert.strictEqual( mw.user.getName(), null, 'user.getName() returns null when anonymous' ); - assert.assertTrue( mw.user.isAnon(), 'user.isAnon() returns true when anonymous' ); - assert.strictEqual( mw.user.getId(), 0, 'user.getId() returns 0 when anonymous' ); + assert.strictEqual( mw.user.getName(), null, 'getName()' ); + assert.strictEqual( mw.user.isAnon(), true, 'isAnon()' ); + assert.strictEqual( mw.user.getId(), 0, 'getId()' ); + } ); - // Not part of startUp module + QUnit.test( 'getters (logged-in)', function ( assert ) { mw.config.set( 'wgUserName', 'John' ); mw.config.set( 'wgUserId', 123 ); - assert.equal( mw.user.getName(), 'John', 'user.getName() returns username when logged-in' ); - assert.assertFalse( mw.user.isAnon(), 'user.isAnon() returns false when logged-in' ); - assert.strictEqual( mw.user.getId(), 123, 'user.getId() returns correct ID when logged-in' ); + assert.equal( mw.user.getName(), 'John', 'getName()' ); + assert.strictEqual( mw.user.isAnon(), false, 'isAnon()' ); + assert.strictEqual( mw.user.getId(), 123, 'getId()' ); - assert.equal( mw.user.id(), 'John', 'user.id Returns username when logged-in' ); + assert.equal( mw.user.id(), 'John', 'user.id()' ); } ); - QUnit.test( 'getUserInfos', 3, function ( assert ) { + QUnit.test( 'getUserInfo', function ( assert ) { mw.config.set( 'wgUserGroups', [ '*', 'user' ] ); mw.user.getGroups( function ( groups ) { @@ -64,7 +64,7 @@ this.server.respond(); } ); - QUnit.test( 'generateRandomSessionId', 4, function ( assert ) { + QUnit.test( 'generateRandomSessionId', function ( assert ) { var result, result2; result = mw.user.generateRandomSessionId(); @@ -77,7 +77,7 @@ } ); - QUnit.test( 'generateRandomSessionId (fallback)', 4, function ( assert ) { + QUnit.test( 'generateRandomSessionId (fallback)', function ( assert ) { var result, result2; // Pretend crypto API is not there to test the Math.random fallback diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js index d697507af6..6dd17f11e1 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js @@ -136,7 +136,7 @@ } ); } ); - QUnit.test( 'getUrl', 13, function ( assert ) { + QUnit.test( 'getUrl', 14, function ( assert ) { var href; mw.config.set( { wgScript: '/w/index.php', @@ -150,6 +150,10 @@ href = mw.util.getUrl( 'Foo:Sandbox? 5+5=10! (test)/sub ' ); assert.equal( href, '/wiki/Foo:Sandbox%3F_5%2B5%3D10!_(test)/sub_', 'complex title' ); + // T149767 + href = mw.util.getUrl( 'My$$test$$$$$title' ); + assert.equal( href, '/wiki/My$$test$$$$$title', 'title with multiple consecutive dollar signs' ); + href = mw.util.getUrl(); assert.equal( href, '/wiki/Foobar', 'default title' ); @@ -237,20 +241,6 @@ assert.strictEqual( mw.util.getParamValue( 'TEST', url ), 'a b+c d', 'Bug 30441: getParamValue must understand "+" encoding of space (multiple spaces)' ); } ); - QUnit.test( 'tooltipAccessKey', 4, function ( assert ) { - this.suppressWarnings(); - - assert.equal( typeof mw.util.tooltipAccessKeyPrefix, 'string', 'tooltipAccessKeyPrefix must be a string' ); - assert.equal( $.type( mw.util.tooltipAccessKeyRegexp ), 'regexp', 'tooltipAccessKeyRegexp is a regexp' ); - assert.ok( mw.util.updateTooltipAccessKeys, 'updateTooltipAccessKeys is non-empty' ); - - 'Example [a]'.replace( mw.util.tooltipAccessKeyRegexp, function ( sub, m1, m2, m3, m4, m5, m6 ) { - assert.equal( m6, 'a', 'tooltipAccessKeyRegexp finds the accesskey hint' ); - } ); - - this.restoreWarnings(); - } ); - QUnit.test( '$content', 2, function ( assert ) { assert.ok( mw.util.$content instanceof jQuery, 'mw.util.$content instance of jQuery' ); assert.strictEqual( mw.util.$content.length, 1, 'mw.util.$content must have length of 1' ); diff --git a/tests/qunit/suites/resources/startup.test.js b/tests/qunit/suites/resources/startup.test.js index 2934b39f3c..045b633695 100644 --- a/tests/qunit/suites/resources/startup.test.js +++ b/tests/qunit/suites/resources/startup.test.js @@ -10,6 +10,7 @@ 'Mozilla/5.0 (Windows NT 6.1.1; rv:5.0) Gecko/20100101 Firefox/5.0', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv:9.0) Gecko/20100101 Firefox/9.0', 'Mozilla/5.0 (Macintosh; I; Intel Mac OS X 11_7_9; de-LI; rv:1.9b4) Gecko/2012010317 Firefox/10.0a4', + 'Mozilla/5.0 (X11; Linux i686; rv:10.0) Gecko/20100101 Firefox/10.0', 'Mozilla/5.0 (Windows NT 6.1; rv:12.0) Gecko/20120403211507 Firefox/12.0', 'Mozilla/5.0 (Windows NT 6.2; Win64; x64; rv:16.0.1) Gecko/20121011 Firefox/16.0.1', // Kindle Fire @@ -46,6 +47,8 @@ 'Mozilla/5.0 (iPhone; U; CPU like Mac OS X; en) AppleWebKit/420.1 (KHTML, like Gecko) Version/3.0 Mobile/3B48b Safari/419.3', // Android 'Mozilla/5.0 (Linux; U; Android 2.1; en-us; Nexus One Build/ERD62) AppleWebKit/530.17 (KHTML, like Gecko) Version/4.0 Mobile Safari/530.17', + // UC Mini (speed mode off) + 'Mozilla/5.0 (Linux; U; Android 6.0.1; en-US; Nexus_5 Build/MMB29S) AppleWebKit/528.5+ (KHTML, like Gecko) Version/3.1.2 Mobile Safari/525.20.1 UCBrowser/10.7.6.805 Mobile', /* Grade C */ @@ -99,12 +102,18 @@ 'Wget/1.10.1 (Red Hat modified)', // Unknown 'I\'m an unknown browser', + 'I\'m an unknown Glass browser', // Empty '' ], blacklisted: [ /* Grade C */ + // PlayStation + 'Mozilla/5.0 (PLAYSTATION 3; 1.10)', + 'Mozilla/5.0 (PLAYSTATION 3; 3.55)', + 'Mozilla/5.0 (PLAYSTATION 3 4.21) AppleWebKit/531.22.8 (KHTML, like Gecko)', + 'Mozilla/5.0 (PlayStation 4 1.70) AppleWebKit/536.26 (KHTML, like Gecko)', // Open WebOS < 1.5 (Palm Pre, Palm Pixi) 'Mozilla/5.0 (webOS/1.0; U; en-US) AppleWebKit/525.27.1 (KHTML, like Gecko) Version/1.0 Safari/525.27.1 Pre/1.0', 'Mozilla/5.0 (webOS/1.4.0; U; en-US) AppleWebKit/532.2 (KHTML, like Gecko) Version/1.0 Safari/532.2 Pixi/1.1 ', @@ -128,7 +137,9 @@ // Google Glass 'Mozilla/5.0 (Linux; U; Android 4.0.4; en-us; Glass 1 Build/IMM76L; XE11) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', // MeeGo - 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13' + 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13', + // UC Mini (speed mode on) + 'Mozilla/5.0 (X11; U; Linux i686; zh-CN; r:1.2.3.4) Gecko/' ] }; diff --git a/tests/testHelpers.inc b/tests/testHelpers.inc deleted file mode 100644 index d04e0fcb54..0000000000 --- a/tests/testHelpers.inc +++ /dev/null @@ -1,874 +0,0 @@ -<?php -/** - * Recording for passing/failing tests. - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 2 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, write to the Free Software Foundation, Inc., - * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - * http://www.gnu.org/copyleft/gpl.html - * - * @file - * @ingroup Testing - */ - -/** - * Interface to record parser test results. - * - * The ITestRecorder is a very simple interface to record the result of - * MediaWiki parser tests. One should call start() before running the - * full parser tests and end() once all the tests have been finished. - * After each test, you should use record() to keep track of your tests - * results. Finally, report() is used to generate a summary of your - * test run, one could dump it to the console for human consumption or - * register the result in a database for tracking purposes. - * - * @since 1.22 - */ -interface ITestRecorder { - - /** - * Called at beginning of the parser test run - */ - public function start(); - - /** - * Called after each test - * @param string $test - * @param integer $subtest - * @param bool $result - */ - public function record( $test, $subtest, $result ); - - /** - * Called before finishing the test run - */ - public function report(); - - /** - * Called at the end of the parser test run - */ - public function end(); - -} - -class TestRecorder implements ITestRecorder { - public $parent; - public $term; - - function __construct( $parent ) { - $this->parent = $parent; - $this->term = $parent->term; - } - - function start() { - $this->total = 0; - $this->success = 0; - } - - function record( $test, $subtest, $result ) { - $this->total++; - $this->success += ( $result ? 1 : 0 ); - } - - function end() { - // dummy - } - - function report() { - if ( $this->total > 0 ) { - $this->reportPercentage( $this->success, $this->total ); - } else { - throw new MWException( "No tests found.\n" ); - } - } - - function reportPercentage( $success, $total ) { - $ratio = wfPercent( 100 * $success / $total ); - print $this->term->color( 1 ) . "Passed $success of $total tests ($ratio)... "; - - if ( $success == $total ) { - print $this->term->color( 32 ) . "ALL TESTS PASSED!"; - } else { - $failed = $total - $success; - print $this->term->color( 31 ) . "$failed tests failed!"; - } - - print $this->term->reset() . "\n"; - - return ( $success == $total ); - } -} - -class DbTestPreviewer extends TestRecorder { - protected $lb; // /< Database load balancer - protected $db; // /< Database connection to the main DB - protected $curRun; // /< run ID number for the current run - protected $prevRun; // /< run ID number for the previous run, if any - protected $results; // /< Result array - - /** - * This should be called before the table prefix is changed - * @param TestRecorder $parent - */ - function __construct( $parent ) { - parent::__construct( $parent ); - - $this->lb = wfGetLBFactory()->newMainLB(); - // This connection will have the wiki's table prefix, not parsertest_ - $this->db = $this->lb->getConnection( DB_MASTER ); - } - - /** - * Set up result recording; insert a record for the run with the date - * and all that fun stuff - */ - function start() { - parent::start(); - - if ( !$this->db->tableExists( 'testrun', __METHOD__ ) - || !$this->db->tableExists( 'testitem', __METHOD__ ) - ) { - print "WARNING> `testrun` table not found in database.\n"; - $this->prevRun = false; - } else { - // We'll make comparisons against the previous run later... - $this->prevRun = $this->db->selectField( 'testrun', 'MAX(tr_id)' ); - } - - $this->results = []; - } - - function getName( $test, $subtest ) { - if ( $subtest ) { - return "$test subtest #$subtest"; - } else { - return $test; - } - } - - function record( $test, $subtest, $result ) { - parent::record( $test, $subtest, $result ); - $this->results[ $this->getName( $test, $subtest ) ] = $result; - } - - function report() { - if ( $this->prevRun ) { - // f = fail, p = pass, n = nonexistent - // codes show before then after - $table = [ - 'fp' => 'previously failing test(s) now PASSING! :)', - 'pn' => 'previously PASSING test(s) removed o_O', - 'np' => 'new PASSING test(s) :)', - - 'pf' => 'previously passing test(s) now FAILING! :(', - 'fn' => 'previously FAILING test(s) removed O_o', - 'nf' => 'new FAILING test(s) :(', - 'ff' => 'still FAILING test(s) :(', - ]; - - $prevResults = []; - - $res = $this->db->select( 'testitem', [ 'ti_name', 'ti_success' ], - [ 'ti_run' => $this->prevRun ], __METHOD__ ); - - foreach ( $res as $row ) { - if ( !$this->parent->regex - || preg_match( "/{$this->parent->regex}/i", $row->ti_name ) - ) { - $prevResults[$row->ti_name] = $row->ti_success; - } - } - - $combined = array_keys( $this->results + $prevResults ); - - # Determine breakdown by change type - $breakdown = []; - foreach ( $combined as $test ) { - if ( !isset( $prevResults[$test] ) ) { - $before = 'n'; - } elseif ( $prevResults[$test] == 1 ) { - $before = 'p'; - } else /* if ( $prevResults[$test] == 0 )*/ { - $before = 'f'; - } - - if ( !isset( $this->results[$test] ) ) { - $after = 'n'; - } elseif ( $this->results[$test] == 1 ) { - $after = 'p'; - } else /*if ( $this->results[$test] == 0 ) */ { - $after = 'f'; - } - - $code = $before . $after; - - if ( isset( $table[$code] ) ) { - $breakdown[$code][$test] = $this->getTestStatusInfo( $test, $after ); - } - } - - # Write out results - foreach ( $table as $code => $label ) { - if ( !empty( $breakdown[$code] ) ) { - $count = count( $breakdown[$code] ); - printf( "\n%4d %s\n", $count, $label ); - - foreach ( $breakdown[$code] as $differing_test_name => $statusInfo ) { - print " * $differing_test_name [$statusInfo]\n"; - } - } - } - } else { - print "No previous test runs to compare against.\n"; - } - - print "\n"; - parent::report(); - } - - /** - * Returns a string giving information about when a test last had a status change. - * Could help to track down when regressions were introduced, as distinct from tests - * which have never passed (which are more change requests than regressions). - * @param string $testname - * @param string $after - * @return string - */ - private function getTestStatusInfo( $testname, $after ) { - // If we're looking at a test that has just been removed, then say when it first appeared. - if ( $after == 'n' ) { - $changedRun = $this->db->selectField( 'testitem', - 'MIN(ti_run)', - [ 'ti_name' => $testname ], - __METHOD__ ); - $appear = $this->db->selectRow( 'testrun', - [ 'tr_date', 'tr_mw_version' ], - [ 'tr_id' => $changedRun ], - __METHOD__ ); - - return "First recorded appearance: " - . date( "d-M-Y H:i:s", strtotime( $appear->tr_date ) ) - . ", " . $appear->tr_mw_version; - } - - // Otherwise, this test has previous recorded results. - // See when this test last had a different result to what we're seeing now. - $conds = [ - 'ti_name' => $testname, - 'ti_success' => ( $after == 'f' ? "1" : "0" ) ]; - - if ( $this->curRun ) { - $conds[] = "ti_run != " . $this->db->addQuotes( $this->curRun ); - } - - $changedRun = $this->db->selectField( 'testitem', 'MAX(ti_run)', $conds, __METHOD__ ); - - // If no record of ever having had a different result. - if ( is_null( $changedRun ) ) { - if ( $after == "f" ) { - return "Has never passed"; - } else { - return "Has never failed"; - } - } - - // Otherwise, we're looking at a test whose status has changed. - // (i.e. it used to work, but now doesn't; or used to fail, but is now fixed.) - // In this situation, give as much info as we can as to when it changed status. - $pre = $this->db->selectRow( 'testrun', - [ 'tr_date', 'tr_mw_version' ], - [ 'tr_id' => $changedRun ], - __METHOD__ ); - $post = $this->db->selectRow( 'testrun', - [ 'tr_date', 'tr_mw_version' ], - [ "tr_id > " . $this->db->addQuotes( $changedRun ) ], - __METHOD__, - [ "LIMIT" => 1, "ORDER BY" => 'tr_id' ] - ); - - if ( $post ) { - $postDate = date( "d-M-Y H:i:s", strtotime( $post->tr_date ) ) . ", {$post->tr_mw_version}"; - } else { - $postDate = 'now'; - } - - return ( $after == "f" ? "Introduced" : "Fixed" ) . " between " - . date( "d-M-Y H:i:s", strtotime( $pre->tr_date ) ) . ", " . $pre->tr_mw_version - . " and $postDate"; - } - - /** - * Close the DB connection - */ - function end() { - $this->lb->closeAll(); - parent::end(); - } -} - -class DbTestRecorder extends DbTestPreviewer { - public $version; - - /** - * Set up result recording; insert a record for the run with the date - * and all that fun stuff - */ - function start() { - $this->db->begin( __METHOD__ ); - - if ( !$this->db->tableExists( 'testrun' ) - || !$this->db->tableExists( 'testitem' ) - ) { - print "WARNING> `testrun` table not found in database. Trying to create table.\n"; - $this->db->sourceFile( $this->db->patchPath( 'patch-testrun.sql' ) ); - echo "OK, resuming.\n"; - } - - parent::start(); - - $this->db->insert( 'testrun', - [ - 'tr_date' => $this->db->timestamp(), - 'tr_mw_version' => $this->version, - 'tr_php_version' => PHP_VERSION, - 'tr_db_version' => $this->db->getServerVersion(), - 'tr_uname' => php_uname() - ], - __METHOD__ ); - if ( $this->db->getType() === 'postgres' ) { - $this->curRun = $this->db->currentSequenceValue( 'testrun_id_seq' ); - } else { - $this->curRun = $this->db->insertId(); - } - } - - /** - * Record an individual test item's success or failure to the db - * - * @param string $test - * @param bool $result - */ - function record( $test, $subtest, $result ) { - parent::record( $test, $subtest, $result ); - - $this->db->insert( 'testitem', - [ - 'ti_run' => $this->curRun, - 'ti_name' => $this->getName( $test, $subtest ), - 'ti_success' => $result ? 1 : 0, - ], - __METHOD__ ); - } - - /** - * Commit transaction and clean up for result recording - */ - function end() { - $this->db->commit( __METHOD__ ); - parent::end(); - } -} - -class TestFileIterator implements Iterator { - private $file; - private $fh; - /** - * @var ParserTest|MediaWikiParserTest An instance of ParserTest (parserTests.php) - * or MediaWikiParserTest (phpunit) - */ - private $parserTest; - private $index = 0; - private $test; - private $section = null; - /** String|null: current test section being analyzed */ - private $sectionData = []; - private $lineNum; - private $eof; - # Create a fake parser tests which never run anything unless - # asked to do so. This will avoid running hooks for a disabled test - private $delayedParserTest; - private $nextSubTest = 0; - - function __construct( $file, $parserTest ) { - $this->file = $file; - $this->fh = fopen( $this->file, "rt" ); - - if ( !$this->fh ) { - throw new MWException( "Couldn't open file '$file'\n" ); - } - - $this->parserTest = $parserTest; - $this->delayedParserTest = new DelayedParserTest(); - - $this->lineNum = $this->index = 0; - } - - function rewind() { - if ( fseek( $this->fh, 0 ) ) { - throw new MWException( "Couldn't fseek to the start of '$this->file'\n" ); - } - - $this->index = -1; - $this->lineNum = 0; - $this->eof = false; - $this->next(); - - return true; - } - - function current() { - return $this->test; - } - - function key() { - return $this->index; - } - - function next() { - if ( $this->readNextTest() ) { - $this->index++; - return true; - } else { - $this->eof = true; - } - } - - function valid() { - return $this->eof != true; - } - - function setupCurrentTest() { - // "input" and "result" are old section names allowed - // for backwards-compatibility. - $input = $this->checkSection( [ 'wikitext', 'input' ], false ); - $result = $this->checkSection( [ 'html/php', 'html/*', 'html', 'result' ], false ); - // some tests have "with tidy" and "without tidy" variants - $tidy = $this->checkSection( [ 'html/php+tidy', 'html+tidy' ], false ); - if ( $tidy != false ) { - if ( $this->nextSubTest == 0 ) { - if ( $result != false ) { - $this->nextSubTest = 1; // rerun non-tidy variant later - } - $result = $tidy; - } else { - $this->nextSubTest = 0; // go on to next test after this - $tidy = false; - } - } - - if ( !isset( $this->sectionData['options'] ) ) { - $this->sectionData['options'] = ''; - } - - if ( !isset( $this->sectionData['config'] ) ) { - $this->sectionData['config'] = ''; - } - - $isDisabled = preg_match( '/\\bdisabled\\b/i', $this->sectionData['options'] ) && - !$this->parserTest->runDisabled; - $isParsoidOnly = preg_match( '/\\bparsoid\\b/i', $this->sectionData['options'] ) && - $result == 'html' && - !$this->parserTest->runParsoid; - $isFiltered = !preg_match( "/" . $this->parserTest->regex . "/i", $this->sectionData['test'] ); - if ( $input == false || $result == false || $isDisabled || $isParsoidOnly || $isFiltered ) { - # disabled test - return false; - } - - # We are really going to run the test, run pending hooks and hooks function - wfDebug( __METHOD__ . " unleashing delayed test for: {$this->sectionData['test']}" ); - $hooksResult = $this->delayedParserTest->unleash( $this->parserTest ); - if ( !$hooksResult ) { - # Some hook reported an issue. Abort. - throw new MWException( "Problem running requested parser hook from the test file" ); - } - - $this->test = [ - 'test' => ParserTest::chomp( $this->sectionData['test'] ), - 'subtest' => $this->nextSubTest, - 'input' => ParserTest::chomp( $this->sectionData[$input] ), - 'result' => ParserTest::chomp( $this->sectionData[$result] ), - 'options' => ParserTest::chomp( $this->sectionData['options'] ), - 'config' => ParserTest::chomp( $this->sectionData['config'] ), - ]; - if ( $tidy != false ) { - $this->test['options'] .= " tidy"; - } - return true; - } - - function readNextTest() { - # Run additional subtests of previous test - while ( $this->nextSubTest > 0 ) { - if ( $this->setupCurrentTest() ) { - return true; - } - } - - $this->clearSection(); - # Reset hooks for the delayed test object - $this->delayedParserTest->reset(); - - while ( false !== ( $line = fgets( $this->fh ) ) ) { - $this->lineNum++; - $matches = []; - - if ( preg_match( '/^!!\s*(\S+)/', $line, $matches ) ) { - $this->section = strtolower( $matches[1] ); - - if ( $this->section == 'endarticle' ) { - $this->checkSection( 'text' ); - $this->checkSection( 'article' ); - - $this->parserTest->addArticle( - ParserTest::chomp( $this->sectionData['article'] ), - $this->sectionData['text'], $this->lineNum ); - - $this->clearSection(); - - continue; - } - - if ( $this->section == 'endhooks' ) { - $this->checkSection( 'hooks' ); - - foreach ( explode( "\n", $this->sectionData['hooks'] ) as $line ) { - $line = trim( $line ); - - if ( $line ) { - $this->delayedParserTest->requireHook( $line ); - } - } - - $this->clearSection(); - - continue; - } - - if ( $this->section == 'endfunctionhooks' ) { - $this->checkSection( 'functionhooks' ); - - foreach ( explode( "\n", $this->sectionData['functionhooks'] ) as $line ) { - $line = trim( $line ); - - if ( $line ) { - $this->delayedParserTest->requireFunctionHook( $line ); - } - } - - $this->clearSection(); - - continue; - } - - if ( $this->section == 'endtransparenthooks' ) { - $this->checkSection( 'transparenthooks' ); - - foreach ( explode( "\n", $this->sectionData['transparenthooks'] ) as $line ) { - $line = trim( $line ); - - if ( $line ) { - $this->delayedParserTest->requireTransparentHook( $line ); - } - } - - $this->clearSection(); - - continue; - } - - if ( $this->section == 'end' ) { - $this->checkSection( 'test' ); - do { - if ( $this->setupCurrentTest() ) { - return true; - } - } while ( $this->nextSubTest > 0 ); - # go on to next test (since this was disabled) - $this->clearSection(); - $this->delayedParserTest->reset(); - continue; - } - - if ( isset( $this->sectionData[$this->section] ) ) { - throw new MWException( "duplicate section '$this->section' " - . "at line {$this->lineNum} of $this->file\n" ); - } - - $this->sectionData[$this->section] = ''; - - continue; - } - - if ( $this->section ) { - $this->sectionData[$this->section] .= $line; - } - } - - return false; - } - - /** - * Clear section name and its data - */ - private function clearSection() { - $this->sectionData = []; - $this->section = null; - - } - - /** - * Verify the current section data has some value for the given token - * name(s) (first parameter). - * Throw an exception if it is not set, referencing current section - * and adding the current file name and line number - * - * @param string|array $tokens Expected token(s) that should have been - * mentioned before closing this section - * @param bool $fatal True iff an exception should be thrown if - * the section is not found. - * @return bool|string - * @throws MWException - */ - private function checkSection( $tokens, $fatal = true ) { - if ( is_null( $this->section ) ) { - throw new MWException( __METHOD__ . " can not verify a null section!\n" ); - } - if ( !is_array( $tokens ) ) { - $tokens = [ $tokens ]; - } - if ( count( $tokens ) == 0 ) { - throw new MWException( __METHOD__ . " can not verify zero sections!\n" ); - } - - $data = $this->sectionData; - $tokens = array_filter( $tokens, function ( $token ) use ( $data ) { - return isset( $data[$token] ); - } ); - - if ( count( $tokens ) == 0 ) { - if ( !$fatal ) { - return false; - } - throw new MWException( sprintf( - "'%s' without '%s' at line %s of %s\n", - $this->section, - implode( ',', $tokens ), - $this->lineNum, - $this->file - ) ); - } - if ( count( $tokens ) > 1 ) { - throw new MWException( sprintf( - "'%s' with unexpected tokens '%s' at line %s of %s\n", - $this->section, - implode( ',', $tokens ), - $this->lineNum, - $this->file - ) ); - } - - return array_values( $tokens )[0]; - } -} - -/** - * An iterator for use as a phpunit data provider. Provides the test arguments - * in the order expected by NewParserTest::testParserTest(). - */ -class TestFileDataProvider extends TestFileIterator { - function current() { - $test = parent::current(); - if ( $test ) { - return [ - $test['test'], - $test['input'], - $test['result'], - $test['options'], - $test['config'], - ]; - } else { - return $test; - } - } -} - -/** - * A class to delay execution of a parser test hooks. - */ -class DelayedParserTest { - - /** Initialized on construction */ - private $hooks; - private $fnHooks; - private $transparentHooks; - - public function __construct() { - $this->reset(); - } - - /** - * Init/reset or forgot about the current delayed test. - * Call to this will erase any hooks function that were pending. - */ - public function reset() { - $this->hooks = []; - $this->fnHooks = []; - $this->transparentHooks = []; - } - - /** - * Called whenever we actually want to run the hook. - * Should be the case if we found the parserTest is not disabled - * @param ParserTest|NewParserTest $parserTest - * @return bool - * @throws MWException - */ - public function unleash( &$parserTest ) { - if ( !( $parserTest instanceof ParserTest || $parserTest instanceof NewParserTest ) ) { - throw new MWException( __METHOD__ . " must be passed an instance of ParserTest or " - . "NewParserTest classes\n" ); - } - - # Trigger delayed hooks. Any failure will make us abort - foreach ( $this->hooks as $hook ) { - $ret = $parserTest->requireHook( $hook ); - if ( !$ret ) { - return false; - } - } - - # Trigger delayed function hooks. Any failure will make us abort - foreach ( $this->fnHooks as $fnHook ) { - $ret = $parserTest->requireFunctionHook( $fnHook ); - if ( !$ret ) { - return false; - } - } - - # Trigger delayed transparent hooks. Any failure will make us abort - foreach ( $this->transparentHooks as $hook ) { - $ret = $parserTest->requireTransparentHook( $hook ); - if ( !$ret ) { - return false; - } - } - - # Delayed execution was successful. - return true; - } - - /** - * Similar to ParserTest object but does not run anything - * Use unleash() to really execute the hook - * @param string $hook - */ - public function requireHook( $hook ) { - $this->hooks[] = $hook; - } - - /** - * Similar to ParserTest object but does not run anything - * Use unleash() to really execute the hook function - * @param string $fnHook - */ - public function requireFunctionHook( $fnHook ) { - $this->fnHooks[] = $fnHook; - } - - /** - * Similar to ParserTest object but does not run anything - * Use unleash() to really execute the hook function - * @param string $hook - */ - public function requireTransparentHook( $hook ) { - $this->transparentHooks[] = $hook; - } - -} - -/** - * Initialize and detect the DjVu files support - */ -class DjVuSupport { - - /** - * Initialises DjVu tools global with default values - */ - public function __construct() { - global $wgDjvuRenderer, $wgDjvuDump, $wgDjvuToXML, $wgFileExtensions, $wgDjvuTxt; - - $wgDjvuRenderer = $wgDjvuRenderer ? $wgDjvuRenderer : '/usr/bin/ddjvu'; - $wgDjvuDump = $wgDjvuDump ? $wgDjvuDump : '/usr/bin/djvudump'; - $wgDjvuToXML = $wgDjvuToXML ? $wgDjvuToXML : '/usr/bin/djvutoxml'; - $wgDjvuTxt = $wgDjvuTxt ? $wgDjvuTxt : '/usr/bin/djvutxt'; - - if ( !in_array( 'djvu', $wgFileExtensions ) ) { - $wgFileExtensions[] = 'djvu'; - } - } - - /** - * Returns true if the DjVu tools are usable - * - * @return bool - */ - public function isEnabled() { - global $wgDjvuRenderer, $wgDjvuDump, $wgDjvuToXML, $wgDjvuTxt; - - return is_executable( $wgDjvuRenderer ) - && is_executable( $wgDjvuDump ) - && is_executable( $wgDjvuToXML ) - && is_executable( $wgDjvuTxt ); - } -} - -/** - * Initialize and detect the tidy support - */ -class TidySupport { - private $internalTidy; - private $externalTidy; - - /** - * Determine if there is a usable tidy. - */ - public function __construct() { - global $wgTidyBin; - - $this->internalTidy = extension_loaded( 'tidy' ) && - class_exists( 'tidy' ) && !wfIsHHVM(); - - $this->externalTidy = is_executable( $wgTidyBin ) || - Installer::locateExecutableInDefaultPaths( [ $wgTidyBin ] ) - !== false; - } - - /** - * Returns true if we should use internal tidy. - * - * @return bool - */ - public function isInternal() { - return $this->internalTidy; - } - - /** - * Returns true if tidy is usable - * - * @return bool - */ - public function isEnabled() { - return $this->internalTidy || $this->externalTidy; - } -} diff --git a/thumb.php b/thumb.php index fca25c55d2..c38b89c251 100644 --- a/thumb.php +++ b/thumb.php @@ -341,6 +341,7 @@ function wfStreamThumb( array $params ) { // Check for thumbnail generation errors... $msg = wfMessage( 'thumbnail_error' ); $errorCode = 500; + if ( !$thumb ) { $errorMsg = $errorMsg ?: $msg->rawParams( 'File::transform() returned false' )->escaped(); if ( $errorMsg instanceof MessageSpecifier && @@ -350,6 +351,7 @@ function wfStreamThumb( array $params ) { } } elseif ( $thumb->isError() ) { $errorMsg = $thumb->getHtmlMsg(); + $errorCode = $thumb->getHttpStatusCode(); } elseif ( !$thumb->hasFile() ) { $errorMsg = $msg->rawParams( 'No path supplied in thumbnail object' )->escaped(); } elseif ( $thumb->fileIsSource() ) {