1<?php
2
3/**
4 * @covers ScribuntoEngineBase
5 * @covers Scribunto_LuaEngine
6 * @covers Scribunto_LuaStandaloneEngine
7 * @covers Scribunto_LuaSandboxEngine
8 * @covers Scribunto_LuaInterpreter
9 * @covers Scribunto_LuaStandaloneInterpreter
10 * @covers Scribunto_LuaSandboxInterpreter
11 */
12class Scribunto_LuaCommonTest extends Scribunto_LuaEngineTestBase {
13	/** @inheritDoc */
14	protected static $moduleName = 'CommonTests';
15
16	/** @var string[] */
17	private static $allowedGlobals = [
18		// Functions
19		'assert',
20		'error',
21		'getfenv',
22		'getmetatable',
23		'ipairs',
24		'next',
25		'pairs',
26		'pcall',
27		'rawequal',
28		'rawget',
29		'rawset',
30		'require',
31		'select',
32		'setfenv',
33		'setmetatable',
34		'tonumber',
35		'tostring',
36		'type',
37		'unpack',
38		'xpcall',
39
40		// Packages
41		'_G',
42		'debug',
43		'math',
44		'mw',
45		'os',
46		'package',
47		'string',
48		'table',
49
50		// Misc
51		'_VERSION',
52	];
53
54	protected function setUp() : void {
55		parent::setUp();
56
57		// Register libraries for self::testPHPLibrary()
58		$this->mergeMwGlobalArrayValue( 'wgHooks', [
59			'ScribuntoExternalLibraries' => [
60				function ( $engine, &$libs ) {
61					$libs += [
62						'CommonTestsLib' => [
63							'class' => Scribunto_LuaCommonTestsLibrary::class,
64							'deferLoad' => true,
65						],
66						'CommonTestsFailLib' => [
67							'class' => Scribunto_LuaCommonTestsFailLibrary::class,
68							'deferLoad' => true,
69						],
70					];
71				}
72			]
73		] );
74
75		// Note this depends on every iteration of the data provider running with a clean parser
76		$this->getEngine()->getParser()->getOptions()->setExpensiveParserFunctionLimit( 10 );
77
78		// Some of the tests need this
79		$interpreter = $this->getEngine()->getInterpreter();
80		$interpreter->callFunction( $interpreter->loadString(
81			'mw.makeProtectedEnvFuncsForTest = mw.makeProtectedEnvFuncs', 'fortest'
82		) );
83	}
84
85	protected function getTestModules() {
86		return parent::getTestModules() + [
87			'CommonTests' => __DIR__ . '/CommonTests.lua',
88			'CommonTests-data' => __DIR__ . '/CommonTests-data.lua',
89			'CommonTests-data-fail1' => __DIR__ . '/CommonTests-data-fail1.lua',
90			'CommonTests-data-fail2' => __DIR__ . '/CommonTests-data-fail2.lua',
91			'CommonTests-data-fail3' => __DIR__ . '/CommonTests-data-fail3.lua',
92			'CommonTests-data-fail4' => __DIR__ . '/CommonTests-data-fail4.lua',
93			'CommonTests-data-fail5' => __DIR__ . '/CommonTests-data-fail5.lua',
94		];
95	}
96
97	public function testNoLeakedGlobals() {
98		$interpreter = $this->getEngine()->getInterpreter();
99
100		list( $actualGlobals ) = $interpreter->callFunction(
101			$interpreter->loadString(
102				'local t = {} for k in pairs( _G ) do t[#t+1] = k end return t',
103				'getglobals'
104			)
105		);
106
107		$leakedGlobals = array_diff( $actualGlobals, self::$allowedGlobals );
108		$this->assertEmpty( $leakedGlobals,
109			'The following globals are leaked: ' . implode( ' ', $leakedGlobals )
110		);
111	}
112
113	public function testPHPLibrary() {
114		$engine = $this->getEngine();
115		$frame = $engine->getParser()->getPreprocessor()->newFrame();
116
117		$title = Title::makeTitle( NS_MODULE, 'TestInfoPassViaPHPLibrary' );
118		$this->extraModules[$title->getFullText()] = '
119			local p = {}
120
121			function p.test()
122				local lib = require( "CommonTestsLib" )
123				return table.concat( { lib.test() }, "; " )
124			end
125
126			function p.setVal( frame )
127				local lib = require( "CommonTestsLib" )
128				lib.val = frame.args[1]
129				lib.foobar.val = frame.args[1]
130			end
131
132			function p.getVal()
133				local lib = require( "CommonTestsLib" )
134				return tostring( lib.val ), tostring( lib.foobar.val )
135			end
136
137			function p.getSetVal( frame )
138				p.setVal( frame )
139				return p.getVal()
140			end
141
142			function p.checkPackage()
143				local ret = {}
144				ret[1] = package.loaded["CommonTestsLib"] == nil
145				require( "CommonTestsLib" )
146				ret[2] = package.loaded["CommonTestsLib"] ~= nil
147				return ret[1], ret[2]
148			end
149
150			function p.libSetVal( frame )
151				local lib = require( "CommonTestsLib" )
152				return lib.setVal( frame )
153			end
154
155			function p.libGetVal()
156				local lib = require( "CommonTestsLib" )
157				return lib.getVal()
158			end
159
160			return p
161		';
162
163		# Test loading
164		$module = $engine->fetchModuleFromParser( $title );
165		$ret = $module->invoke( 'test', $frame->newChild() );
166		$this->assertSame( 'Test option; Test function', $ret,
167			'Library can be loaded and called' );
168
169		# Test package.loaded
170		$module = $engine->fetchModuleFromParser( $title );
171		$ret = $module->invoke( 'checkPackage', $frame->newChild() );
172		$this->assertSame( 'truetrue', $ret,
173			'package.loaded is right on the first call' );
174		$ret = $module->invoke( 'checkPackage', $frame->newChild() );
175		$this->assertSame( 'truetrue', $ret,
176			'package.loaded is right on the second call' );
177
178		# Test caching for require
179		$args = $engine->getParser()->getPreprocessor()->newPartNodeArray( [ 1 => 'cached' ] );
180		$ret = $module->invoke( 'getSetVal', $frame->newChild( $args ) );
181		$this->assertSame( 'cachedcached', $ret,
182			'same loaded table is returned by multiple require calls' );
183
184		# Test no data communication between invokes
185		$module = $engine->fetchModuleFromParser( $title );
186		$args = $engine->getParser()->getPreprocessor()->newPartNodeArray( [ 1 => 'fail' ] );
187		$module->invoke( 'setVal', $frame->newChild( $args ) );
188		$ret = $module->invoke( 'getVal', $frame->newChild() );
189		$this->assertSame( 'nilnope', $ret,
190			'same loaded table is not shared between invokes' );
191
192		# Test that the library isn't being recreated between invokes
193		$module = $engine->fetchModuleFromParser( $title );
194		$ret = $module->invoke( 'libGetVal', $frame->newChild() );
195		$this->assertSame( 'nil', $ret, 'sanity check' );
196		$args = $engine->getParser()->getPreprocessor()->newPartNodeArray( [ 1 => 'ok' ] );
197		$module->invoke( 'libSetVal', $frame->newChild( $args ) );
198
199		$module = $engine->fetchModuleFromParser( $title );
200		$ret = $module->invoke( 'libGetVal', $frame->newChild() );
201		$this->assertSame( 'ok', $ret,
202			'library is not recreated between invokes' );
203	}
204
205	public function testModuleStringExtend() {
206		$engine = $this->getEngine();
207		$interpreter = $engine->getInterpreter();
208
209		$interpreter->callFunction(
210			$interpreter->loadString( 'string.testModuleStringExtend = "ok"', 'extendstring' )
211		);
212		$ret = $interpreter->callFunction(
213			$interpreter->loadString( 'return ("").testModuleStringExtend', 'teststring1' )
214		);
215		$this->assertSame( [ 'ok' ], $ret, 'string can be extended' );
216
217		$this->extraModules['Module:testModuleStringExtend'] = '
218			return {
219				test = function() return ("").testModuleStringExtend end
220			}
221			';
222		$module = $engine->fetchModuleFromParser(
223			Title::makeTitle( NS_MODULE, 'testModuleStringExtend' )
224		);
225		$ret = $interpreter->callFunction(
226			$engine->executeModule( $module->getInitChunk(), 'test', null )
227		);
228		$this->assertSame( [ 'ok' ], $ret, 'string extension can be used from module' );
229
230		$this->extraModules['Module:testModuleStringExtend2'] = '
231			return {
232				test = function()
233					string.testModuleStringExtend = "fail"
234					return ("").testModuleStringExtend
235				end
236			}
237			';
238		$module = $engine->fetchModuleFromParser(
239			Title::makeTitle( NS_MODULE, 'testModuleStringExtend2' )
240		);
241		$ret = $interpreter->callFunction(
242			$engine->executeModule( $module->getInitChunk(), 'test', null )
243		);
244		$this->assertSame( [ 'ok' ], $ret, 'string extension cannot be modified from module' );
245		$ret = $interpreter->callFunction(
246			$interpreter->loadString( 'return string.testModuleStringExtend', 'teststring2' )
247		);
248		$this->assertSame( [ 'ok' ], $ret, 'string extension cannot be modified from module' );
249
250		$ret = $engine->runConsole( [
251			'prevQuestions' => [],
252			'question' => '=("").testModuleStringExtend',
253			'content' => 'return {}',
254			'title' => Title::makeTitle( NS_MODULE, 'dummy' ),
255		] );
256		$this->assertSame( 'ok', $ret['return'], 'string extension can be used from console' );
257
258		$ret = $engine->runConsole( [
259			'prevQuestions' => [ 'string.fail = "fail"' ],
260			'question' => '=("").fail',
261			'content' => 'return {}',
262			'title' => Title::makeTitle( NS_MODULE, 'dummy' ),
263		] );
264		$this->assertSame( 'nil', $ret['return'], 'string cannot be extended from console' );
265
266		$ret = $engine->runConsole( [
267			'prevQuestions' => [ 'string.testModuleStringExtend = "fail"' ],
268			'question' => '=("").testModuleStringExtend',
269			'content' => 'return {}',
270			'title' => Title::makeTitle( NS_MODULE, 'dummy' ),
271		] );
272		$this->assertSame( 'ok', $ret['return'], 'string extension cannot be modified from console' );
273		$ret = $interpreter->callFunction(
274			$interpreter->loadString( 'return string.testModuleStringExtend', 'teststring3' )
275		);
276		$this->assertSame( [ 'ok' ], $ret, 'string extension cannot be modified from console' );
277
278		$interpreter->callFunction(
279			$interpreter->loadString( 'string.testModuleStringExtend = nil', 'unextendstring' )
280		);
281	}
282
283	public function testLoadDataLoadedOnce() {
284		$engine = $this->getEngine();
285		$interpreter = $engine->getInterpreter();
286		$frame = $engine->getParser()->getPreprocessor()->newFrame();
287
288		$loadcount = 0;
289		$interpreter->callFunction(
290			$interpreter->loadString( 'mw.markLoaded = ...', 'fortest' ),
291			$interpreter->wrapPHPFunction( function () use ( &$loadcount ) {
292				$loadcount++;
293			} )
294		);
295		$this->extraModules['Module:TestLoadDataLoadedOnce-data'] = '
296			mw.markLoaded()
297			return {}
298		';
299		$this->extraModules['Module:TestLoadDataLoadedOnce'] = '
300			local data = mw.loadData( "Module:TestLoadDataLoadedOnce-data" )
301			return {
302				foo = function() end,
303				bar = function()
304					return tostring( package.loaded["Module:TestLoadDataLoadedOnce-data"] )
305				end,
306			}
307		';
308
309		// Make sure data module isn't parsed twice. Simulate several {{#invoke:}}s
310		$title = Title::makeTitle( NS_MODULE, 'TestLoadDataLoadedOnce' );
311		for ( $i = 0; $i < 10; $i++ ) {
312			$module = $engine->fetchModuleFromParser( $title );
313			$module->invoke( 'foo', $frame->newChild() );
314		}
315		$this->assertSame( 1, $loadcount, 'data module was loaded more than once' );
316
317		// Make sure data module isn't in package.loaded
318		$this->assertSame( 'nil', $module->invoke( 'bar', $frame ),
319			'data module was stored in module\'s package.loaded'
320		);
321		$this->assertSame( [ 'nil' ],
322			$interpreter->callFunction( $interpreter->loadString(
323				'return tostring( package.loaded["Module:TestLoadDataLoadedOnce-data"] )', 'getLoaded'
324			) ),
325			'data module was stored in top level\'s package.loaded'
326		);
327	}
328
329	public function testFrames() {
330		$engine = $this->getEngine();
331
332		$ret = $engine->runConsole( [
333			'prevQuestions' => [],
334			'question' => '=mw.getCurrentFrame()',
335			'content' => 'return {}',
336			'title' => Title::makeTitle( NS_MODULE, 'dummy' ),
337		] );
338		$this->assertSame( 'table', $ret['return'], 'frames can be used in the console' );
339
340		$ret = $engine->runConsole( [
341			'prevQuestions' => [],
342			'question' => '=mw.getCurrentFrame():newChild{}',
343			'content' => 'return {}',
344			'title' => Title::makeTitle( NS_MODULE, 'dummy' ),
345		] );
346		$this->assertSame( 'table', $ret['return'], 'child frames can be created' );
347
348		$ret = $engine->runConsole( [
349			'prevQuestions' => [
350				'f = mw.getCurrentFrame():newChild{ args = { "ok" } }',
351				'f2 = f:newChild{ args = {} }'
352			],
353			'question' => '=f2:getParent().args[1], f2:getParent():getParent()',
354			'content' => 'return {}',
355			'title' => Title::makeTitle( NS_MODULE, 'dummy' ),
356		] );
357		$this->assertSame( "ok\ttable", $ret['return'], 'child frames have correct parents' );
358	}
359
360	public function testCallParserFunction() {
361		$engine = $this->getEngine();
362		$parser = $engine->getParser();
363
364		$args = [
365			'prevQuestions' => [],
366			'content' => 'return {}',
367			'title' => Title::makeTitle( NS_MODULE, 'dummy' ),
368		];
369
370		// Test argument calling conventions
371		$ret = $engine->runConsole( [
372			'question' => '=mw.getCurrentFrame():callParserFunction{
373				name = "urlencode", args = { "x x", "wiki" }
374			}',
375		] + $args );
376		$this->assertSame( "x_x", $ret['return'],
377			'callParserFunction works for {{urlencode:x x|wiki}} (named args w/table)'
378		);
379
380		$ret = $engine->runConsole( [
381			'question' => '=mw.getCurrentFrame():callParserFunction{
382				name = "urlencode", args = "x x"
383			}',
384		] + $args );
385		$this->assertSame( "x+x", $ret['return'],
386			'callParserFunction works for {{urlencode:x x}} (named args w/scalar)'
387		);
388
389		$ret = $engine->runConsole( [
390			'question' => '=mw.getCurrentFrame():callParserFunction( "urlencode", { "x x", "wiki" } )',
391		] + $args );
392		$this->assertSame( "x_x", $ret['return'],
393			'callParserFunction works for {{urlencode:x x|wiki}} (positional args w/table)'
394		);
395
396		$ret = $engine->runConsole( [
397			'question' => '=mw.getCurrentFrame():callParserFunction( "urlencode", "x x", "wiki" )',
398		] + $args );
399		$this->assertSame( "x_x", $ret['return'],
400			'callParserFunction works for {{urlencode:x x|wiki}} (positional args w/scalars)'
401		);
402
403		$ret = $engine->runConsole( [
404			'question' => '=mw.getCurrentFrame():callParserFunction{
405				name = "urlencode:x x", args = { "wiki" }
406			}',
407		] + $args );
408		$this->assertSame( "x_x", $ret['return'],
409			'callParserFunction works for {{urlencode:x x|wiki}} (colon in name, named args w/table)'
410		);
411
412		$ret = $engine->runConsole( [
413			'question' => '=mw.getCurrentFrame():callParserFunction{
414				name = "urlencode:x x", args = "wiki"
415			}',
416		] + $args );
417		$this->assertSame( "x_x", $ret['return'],
418			'callParserFunction works for {{urlencode:x x|wiki}} (colon in name, named args w/scalar)'
419		);
420
421		$ret = $engine->runConsole( [
422			'question' => '=mw.getCurrentFrame():callParserFunction( "urlencode:x x", { "wiki" } )',
423		] + $args );
424		$this->assertSame( "x_x", $ret['return'],
425			'callParserFunction works for {{urlencode:x x|wiki}} (colon in name, positional args w/table)'
426		);
427
428		$ret = $engine->runConsole( [
429			'question' => '=mw.getCurrentFrame():callParserFunction( "urlencode:x x", "wiki" )',
430		] + $args );
431		$this->assertSame( "x_x", $ret['return'],
432			'callParserFunction works for {{urlencode:x x|wiki}} (colon in name, positional args w/scalars)'
433		);
434
435		// Test named args to the parser function
436		$ret = $engine->runConsole( [
437			'question' => '=mw.getCurrentFrame():callParserFunction( "#tag:pre",
438				{ "foo", style = "margin-left: 1.6em" }
439			)',
440		] + $args );
441		$this->assertSame(
442			'<pre style="margin-left: 1.6em">foo</pre>',
443			$parser->getStripState()->unstripBoth( $ret['return'] ),
444			'callParserFunction works for {{#tag:pre|foo|style=margin-left: 1.6em}}'
445		);
446
447		// Test extensionTag
448		$ret = $engine->runConsole( [
449			'question' => '=mw.getCurrentFrame():extensionTag( "pre", "foo",
450				{ style = "margin-left: 1.6em" }
451			)',
452		] + $args );
453		$this->assertSame(
454			'<pre style="margin-left: 1.6em">foo</pre>',
455			$parser->getStripState()->unstripBoth( $ret['return'] ),
456			'extensionTag works for {{#tag:pre|foo|style=margin-left: 1.6em}}'
457		);
458
459		$ret = $engine->runConsole( [
460			'question' => '=mw.getCurrentFrame():extensionTag{ name = "pre", content = "foo",
461				args = { style = "margin-left: 1.6em" }
462			}',
463		] + $args );
464		$this->assertSame(
465			'<pre style="margin-left: 1.6em">foo</pre>',
466			$parser->getStripState()->unstripBoth( $ret['return'] ),
467			'extensionTag works for {{#tag:pre|foo|style=margin-left: 1.6em}}'
468		);
469
470		// Test calling a non-existent function
471		try {
472			$ret = $engine->runConsole( [
473				'question' => '=mw.getCurrentFrame():callParserFunction{
474					name = "thisDoesNotExist", args = { "" }
475				}',
476			] + $args );
477			$this->fail( "Expected LuaError not thrown for nonexistent parser function" );
478		} catch ( Scribunto_LuaError $err ) {
479			$this->assertSame(
480				'Lua error: callParserFunction: function "thisDoesNotExist" was not found.',
481				$err->getMessage(),
482				'callParserFunction correctly errors for nonexistent function'
483			);
484		}
485	}
486
487	public function testBug62291() {
488		$engine = $this->getEngine();
489		$frame = $engine->getParser()->getPreprocessor()->newFrame();
490
491		$this->extraModules['Module:Bug62291'] = '
492			local p = {}
493			function p.foo()
494				return table.concat( {
495					math.random(), math.random(), math.random(), math.random(), math.random()
496				}, ", " )
497			end
498			function p.bar()
499				local t = {}
500				t[1] = p.foo()
501				t[2] = mw.getCurrentFrame():preprocess( "{{#invoke:Bug62291|bar2}}" )
502				t[3] = p.foo()
503				return table.concat( t, "; " )
504			end
505			function p.bar2()
506				return "bar2 called"
507			end
508			return p
509		';
510
511		$title = Title::makeTitle( NS_MODULE, 'Bug62291' );
512		$module = $engine->fetchModuleFromParser( $title );
513
514		// Make sure multiple invokes return the same text
515		$r1 = $module->invoke( 'foo', $frame->newChild() );
516		$r2 = $module->invoke( 'foo', $frame->newChild() );
517		$this->assertSame( $r1, $r2, 'Multiple invokes returned different sets of random numbers' );
518
519		// Make sure a recursive invoke doesn't reset the PRNG
520		$r1 = $module->invoke( 'bar', $frame->newChild() );
521		$r = explode( '; ', $r1 );
522		$this->assertNotSame( $r[0], $r[2], 'Recursive invoke reset PRNG' );
523		$this->assertSame( 'bar2 called', $r[1], 'Sanity check failed' );
524
525		// But a second invoke does
526		$r2 = $module->invoke( 'bar', $frame->newChild() );
527		$this->assertSame( $r1, $r2,
528			'Multiple invokes with recursive invoke returned different sets of random numbers' );
529	}
530
531	public function testOsDateTimeTTLs() {
532		$engine = $this->getEngine();
533		$pp = $engine->getParser()->getPreprocessor();
534
535		$this->extraModules['Module:DateTime'] = '
536		local p = {}
537		function p.day()
538			return os.date( "%d" )
539		end
540		function p.AMPM()
541			return os.date( "%p" )
542		end
543		function p.hour()
544			return os.date( "%H" )
545		end
546		function p.minute()
547			return os.date( "%M" )
548		end
549		function p.second()
550			return os.date( "%S" )
551		end
552		function p.table()
553			return os.date( "*t" )
554		end
555		function p.tablesec()
556			return os.date( "*t" ).sec
557		end
558		function p.time()
559			return os.time()
560		end
561		function p.specificDateAndTime()
562			return os.date("%S", os.time{year = 2013, month = 1, day = 1})
563		end
564		return p
565		';
566
567		$title = Title::makeTitle( NS_MODULE, 'DateTime' );
568		$module = $engine->fetchModuleFromParser( $title );
569
570		$frame = $pp->newFrame();
571		$module->invoke( 'day', $frame );
572		$this->assertNotNull( $frame->getTTL(), 'TTL must be set when day is requested' );
573		$this->assertLessThanOrEqual( 86400, $frame->getTTL(),
574			'TTL must not exceed 1 day when day is requested' );
575
576		$frame = $pp->newFrame();
577		$module->invoke( 'AMPM', $frame );
578		$this->assertNotNull( $frame->getTTL(), 'TTL must be set when AM/PM is requested' );
579		$this->assertLessThanOrEqual( 43200, $frame->getTTL(),
580			'TTL must not exceed 12 hours when AM/PM is requested' );
581
582		$frame = $pp->newFrame();
583		$module->invoke( 'hour', $frame );
584		$this->assertNotNull( $frame->getTTL(), 'TTL must be set when hour is requested' );
585		$this->assertLessThanOrEqual( 3600, $frame->getTTL(),
586			'TTL must not exceed 1 hour when hours are requested' );
587
588		$frame = $pp->newFrame();
589		$module->invoke( 'minute', $frame );
590		$this->assertNotNull( $frame->getTTL(), 'TTL must be set when minutes are requested' );
591		$this->assertLessThanOrEqual( 60, $frame->getTTL(),
592			'TTL must not exceed 1 minute when minutes are requested' );
593
594		$frame = $pp->newFrame();
595		$module->invoke( 'second', $frame );
596		$this->assertSame( 1, $frame->getTTL(),
597			'TTL must be equal to 1 second when seconds are requested' );
598
599		$frame = $pp->newFrame();
600		$module->invoke( 'table', $frame );
601		$this->assertNull( $frame->getTTL(),
602			'TTL must not be set when os.date( "*t" ) is called but no values are looked at' );
603
604		$frame = $pp->newFrame();
605		$module->invoke( 'tablesec', $frame );
606		$this->assertSame( 1, $frame->getTTL(),
607			'TTL must be equal to 1 second when seconds are requested from a table' );
608
609		$frame = $pp->newFrame();
610		$module->invoke( 'time', $frame );
611		$this->assertSame( 1, $frame->getTTL(),
612			'TTL must be equal to 1 second when os.time() is called' );
613
614		$frame = $pp->newFrame();
615		$module->invoke( 'specificDateAndTime', $frame );
616		$this->assertNull( $frame->getTTL(),
617			'TTL must not be set when os.date() or os.time() are called with a specific time' );
618	}
619
620	/**
621	 * @dataProvider provideVolatileCaching
622	 */
623	public function testVolatileCaching( $func ) {
624		$engine = $this->getEngine();
625		$parser = $engine->getParser();
626		$pp = $parser->getPreprocessor();
627
628		$count = 0;
629		$parser->setHook( 'scribuntocount', function ( $str, $argv, $parser, $frame ) use ( &$count ) {
630			$frame->setVolatile();
631			return ++$count;
632		} );
633
634		$this->extraModules['Template:ScribuntoTestVolatileCaching'] = '<scribuntocount/>';
635		$this->extraModules['Module:TestVolatileCaching'] = '
636			return {
637				preprocess = function ( frame )
638					return frame:preprocess( "<scribuntocount/>" )
639				end,
640				extensionTag = function ( frame )
641					return frame:extensionTag( "scribuntocount" )
642				end,
643				expandTemplate = function ( frame )
644					return frame:expandTemplate{ title = "ScribuntoTestVolatileCaching" }
645				end,
646			}
647		';
648
649		$frame = $pp->newFrame();
650		$count = 0;
651		$wikitext = "{{#invoke:TestVolatileCaching|$func}}";
652		$text = $frame->expand( $pp->preprocessToObj( "$wikitext $wikitext" ) );
653		$text = $parser->getStripState()->unstripBoth( $text );
654		$this->assertTrue( $frame->isVolatile(), "Frame is marked volatile" );
655		$this->assertEquals( '1 2', $text, "Volatile wikitext was not cached" );
656	}
657
658	public function provideVolatileCaching() {
659		return [
660			[ 'preprocess' ],
661			[ 'extensionTag' ],
662			[ 'expandTemplate' ],
663		];
664	}
665
666	public function testGetCurrentFrameAndMWLoadData() {
667		$engine = $this->getEngine();
668		$parser = $engine->getParser();
669		$pp = $parser->getPreprocessor();
670
671		$this->extraModules['Module:Bug65687'] = '
672			return {
673				test = function ( frame )
674					return mw.loadData( "Module:Bug65687-LD" )[1]
675				end
676			}
677		';
678		$this->extraModules['Module:Bug65687-LD'] = 'return { mw.getCurrentFrame().args[1] or "ok" }';
679
680		$frame = $pp->newFrame();
681		$text = $frame->expand( $pp->preprocessToObj( "{{#invoke:Bug65687|test|foo}}" ) );
682		$text = $parser->getStripState()->unstripBoth( $text );
683		$this->assertEquals( 'ok', $text, 'mw.loadData allowed access to frame args' );
684	}
685
686	public function testGetCurrentFrameAtModuleScope() {
687		$engine = $this->getEngine();
688		$parser = $engine->getParser();
689		$pp = $parser->getPreprocessor();
690
691		$this->extraModules['Module:Bug67498-directly'] = '
692			local f = mw.getCurrentFrame()
693			local f2 = f and f.args[1] or "<none>"
694
695			return {
696				test = function ( frame )
697					return ( f and f.args[1] or "<none>" ) .. " " .. f2
698				end
699			}
700		';
701		$this->extraModules['Module:Bug67498-statically'] = '
702			local M = require( "Module:Bug67498-directly" )
703			return {
704				test = function ( frame )
705					return M.test( frame )
706				end
707			}
708		';
709		$this->extraModules['Module:Bug67498-dynamically'] = '
710			return {
711				test = function ( frame )
712					local M = require( "Module:Bug67498-directly" )
713					return M.test( frame )
714				end
715			}
716		';
717
718		foreach ( [ 'directly', 'statically', 'dynamically' ] as $how ) {
719			$frame = $pp->newFrame();
720			$text = $frame->expand( $pp->preprocessToObj(
721				"{{#invoke:Bug67498-$how|test|foo}} -- {{#invoke:Bug67498-$how|test|bar}}"
722			) );
723			$text = $parser->getStripState()->unstripBoth( $text );
724			$text = explode( ' -- ', $text );
725			$this->assertEquals( 'foo foo', $text[0],
726				"mw.getCurrentFrame() failed from a module loaded $how"
727			);
728			$this->assertEquals( 'bar bar', $text[1],
729				"mw.getCurrentFrame() cached the frame from a module loaded $how"
730			);
731		}
732	}
733
734	public function testGetCurrentFrameAtModuleScopeT234368() {
735		$engine = $this->getEngine();
736		$parser = $engine->getParser();
737		$pp = $parser->getPreprocessor();
738
739		$this->extraModules['Module:Outer'] = '
740			local p = {}
741
742			function p.echo( frame )
743				return "(Outer: 1=" .. frame.args[1] .. ", 2=" .. frame.args[2] .. ")"
744			end
745
746			return p
747		';
748		$this->extraModules['Module:Inner'] = '
749			local p = {}
750
751			local f = mw.getCurrentFrame()
752			local name = f:getTitle()
753			local arg1 = f.args[1]
754
755			function p.test( frame )
756				local f2 = mw.getCurrentFrame()
757				return "(Inner: mod_name=" .. name .. ", mod_1=" .. arg1 .. ", name=" .. f2:getTitle() ..
758					", 1=".. f2.args[1] .. ")"
759			end
760
761			return p
762		';
763
764		$frame = $pp->newFrame();
765		$text = $frame->expand( $pp->preprocessToObj(
766			"{{#invoke:Outer|echo|oarg|{{#invoke:Inner|test|iarg}}}}"
767		) );
768		$text = $parser->getStripState()->unstripBoth( $text );
769		$this->assertSame(
770			'(Outer: 1=oarg, 2=(Inner: mod_name=Module:Inner, mod_1=iarg, name=Module:Inner, 1=iarg))',
771			$text
772		);
773	}
774
775	public function testNonUtf8Errors() {
776		$engine = $this->getEngine();
777		$parser = $engine->getParser();
778
779		$this->extraModules['Module:T208689'] = '
780			local p = {}
781
782			p["foo\255bar"] = function ()
783				error( "error\255bar" )
784			end
785
786			p.foo = function ()
787				p["foo\255bar"]()
788			end
789
790			return p
791		';
792
793		// As via the API
794		try {
795			$engine->runConsole( [
796				'prevQuestions' => [],
797				'question' => 'p.foo()',
798				'title' => Title::newFromText( 'Module:T208689' ),
799				'content' => $this->extraModules['Module:T208689'],
800			] );
801			$this->fail( 'Expected exception not thrown' );
802		} catch ( ScribuntoException $e ) {
803			$this->assertTrue( mb_check_encoding( $e->getMessage(), 'UTF-8' ), 'Message is UTF-8' );
804			$this->assertTrue( mb_check_encoding( $e->getScriptTraceHtml(), 'UTF-8' ), 'Message is UTF-8' );
805		}
806
807		// Via the parser
808		$text = $parser->recursiveTagParseFully( '{{#invoke:T208689|foo}}' );
809		$this->assertTrue( mb_check_encoding( $text, 'UTF-8' ), 'Parser output is UTF-8' );
810		$vars = $parser->getOutput()->getJsConfigVars();
811		$this->assertArrayHasKey( 'ScribuntoErrors', $vars );
812		foreach ( $vars['ScribuntoErrors'] as $err ) {
813			$this->assertTrue( mb_check_encoding( $err, 'UTF-8' ), 'JS config vars are UTF-8' );
814		}
815	}
816
817	public function testT236092() {
818		$engine = $this->getEngine();
819		$parser = $engine->getParser();
820		$pp = $parser->getPreprocessor();
821
822		$this->extraModules['Module:T236092'] = '
823			local p = {}
824			p.foo = mw.isSubsting
825			return p
826		';
827
828		$frame = $pp->newFrame();
829		$text = $frame->expand( $pp->preprocessToObj( ">{{#invoke:T236092|foo}}<" ) );
830		$text = $parser->getStripState()->unstripBoth( $text );
831		$this->assertSame( '>false<', $text );
832	}
833
834	public function testAddWarning() {
835		$engine = $this->getEngine();
836		$parser = $engine->getParser();
837		$pp = $parser->getPreprocessor();
838
839		$this->extraModules['Module:TestAddWarning'] = '
840			local p = {}
841
842			p.foo = function ()
843				mw.addWarning( "Don\'t panic!" )
844				return "ok"
845			end
846
847			return p
848		';
849
850		$frame = $pp->newFrame();
851		$text = $frame->expand( $pp->preprocessToObj( ">{{#invoke:TestAddWarning|foo}}<" ) );
852		$text = $parser->getStripState()->unstripBoth( $text );
853		$this->assertSame( '>ok<', $text );
854		$this->assertSame( [ 'Don\'t panic!' ], $parser->getOutput()->getWarnings() );
855	}
856}
857