ImplementationTest.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. <?php
  2. namespace Nesk\Rialto\Tests;
  3. use Monolog\Logger;
  4. use Nesk\Rialto\Data\JsFunction;
  5. use Nesk\Rialto\Exceptions\Node;
  6. use Nesk\Rialto\Data\BasicResource;
  7. use Symfony\Component\Process\Process;
  8. use Nesk\Rialto\Tests\Implementation\Resources\Stats;
  9. use Nesk\Rialto\Tests\Implementation\{FsWithProcessDelegation, FsWithoutProcessDelegation};
  10. class ImplementationTest extends TestCase
  11. {
  12. const JS_FUNCTION_CREATE_DEPRECATION_PATTERN = '/^Nesk\\\\Rialto\\\\Data\\\\JsFunction::create\(\)/';
  13. public function setUp(): void
  14. {
  15. parent::setUp();
  16. $this->dirPath = realpath(__DIR__.'/resources');
  17. $this->filePath = "{$this->dirPath}/file";
  18. $this->fs = $this->canPopulateProperty('fs') ? new FsWithProcessDelegation : null;
  19. }
  20. public function tearDown(): void
  21. {
  22. $this->fs = null;
  23. }
  24. /** @test */
  25. public function can_call_method_and_get_its_return_value()
  26. {
  27. $content = $this->fs->readFileSync($this->filePath, 'utf8');
  28. $this->assertEquals('Hello world!', $content);
  29. }
  30. /** @test */
  31. public function can_get_property()
  32. {
  33. $constants = $this->fs->constants;
  34. $this->assertInternalType('array', $constants);
  35. }
  36. /** @test */
  37. public function can_set_property()
  38. {
  39. $this->fs->foo = 'bar';
  40. $this->assertEquals('bar', $this->fs->foo);
  41. $this->fs->foo = null;
  42. $this->assertNull($this->fs->foo);
  43. }
  44. /** @test */
  45. public function can_return_basic_resources()
  46. {
  47. $resource = $this->fs->readFileSync($this->filePath);
  48. $this->assertInstanceOf(BasicResource::class, $resource);
  49. }
  50. /** @test */
  51. public function can_return_specific_resources()
  52. {
  53. $resource = $this->fs->statSync($this->filePath);
  54. $this->assertInstanceOf(Stats::class, $resource);
  55. }
  56. /** @test */
  57. public function can_cast_resources_to_string()
  58. {
  59. $resource = $this->fs->statSync($this->filePath);
  60. $this->assertEquals('[object Object]', (string) $resource);
  61. }
  62. /**
  63. * @test
  64. * @dontPopulateProperties fs
  65. */
  66. public function can_omit_process_delegation()
  67. {
  68. $this->fs = new FsWithoutProcessDelegation;
  69. $resource = $this->fs->statSync($this->filePath);
  70. $this->assertInstanceOf(BasicResource::class, $resource);
  71. $this->assertNotInstanceOf(Stats::class, $resource);
  72. }
  73. /** @test */
  74. public function can_use_nested_resources()
  75. {
  76. $resources = $this->fs->multipleStatSync($this->dirPath, $this->filePath);
  77. $this->assertCount(2, $resources);
  78. $this->assertContainsOnlyInstancesOf(Stats::class, $resources);
  79. $isFile = $this->fs->multipleResourcesIsFile($resources);
  80. $this->assertFalse($isFile[0]);
  81. $this->assertTrue($isFile[1]);
  82. }
  83. /** @test */
  84. public function can_use_multiple_resources_without_confusion()
  85. {
  86. $dirStats = $this->fs->statSync($this->dirPath);
  87. $fileStats = $this->fs->statSync($this->filePath);
  88. $this->assertInstanceOf(Stats::class, $dirStats);
  89. $this->assertInstanceOf(Stats::class, $fileStats);
  90. $this->assertTrue($dirStats->isDirectory());
  91. $this->assertTrue($fileStats->isFile());
  92. }
  93. /** @test */
  94. public function can_return_multiple_times_the_same_resource()
  95. {
  96. $stats1 = $this->fs->Stats;
  97. $stats2 = $this->fs->Stats;
  98. $this->assertEquals($stats1, $stats2);
  99. }
  100. /**
  101. * @test
  102. * @group js-functions
  103. */
  104. public function can_use_js_functions_with_a_body()
  105. {
  106. $functions = [
  107. $this->ignoreUserDeprecation(self::JS_FUNCTION_CREATE_DEPRECATION_PATTERN, function () {
  108. return JsFunction::create("return 'Simple callback';");
  109. }),
  110. JsFunction::createWithBody("return 'Simple callback';"),
  111. ];
  112. foreach ($functions as $function) {
  113. $value = $this->fs->runCallback($function);
  114. $this->assertEquals('Simple callback', $value);
  115. }
  116. }
  117. /**
  118. * @test
  119. * @group js-functions
  120. */
  121. public function can_use_js_functions_with_parameters()
  122. {
  123. $functions = [
  124. $this->ignoreUserDeprecation(self::JS_FUNCTION_CREATE_DEPRECATION_PATTERN, function () {
  125. return JsFunction::create(['fs'], "
  126. return 'Callback using arguments: ' + fs.constructor.name;
  127. ");
  128. }),
  129. JsFunction::createWithParameters(['fs'])
  130. ->body("return 'Callback using arguments: ' + fs.constructor.name;"),
  131. ];
  132. foreach ($functions as $function) {
  133. $value = $this->fs->runCallback($function);
  134. $this->assertEquals('Callback using arguments: Object', $value);
  135. }
  136. }
  137. /**
  138. * @test
  139. * @group js-functions
  140. */
  141. public function can_use_js_functions_with_scope()
  142. {
  143. $functions = [
  144. $this->ignoreUserDeprecation(self::JS_FUNCTION_CREATE_DEPRECATION_PATTERN, function () {
  145. return JsFunction::create("
  146. return 'Callback using scope: ' + foo;
  147. ", ['foo' => 'bar']);
  148. }),
  149. JsFunction::createWithScope(['foo' => 'bar'])
  150. ->body("return 'Callback using scope: ' + foo;"),
  151. ];
  152. foreach ($functions as $function) {
  153. $value = $this->fs->runCallback($function);
  154. $this->assertEquals('Callback using scope: bar', $value);
  155. }
  156. }
  157. /**
  158. * @test
  159. * @group js-functions
  160. */
  161. public function can_use_resources_in_js_functions()
  162. {
  163. $fileStats = $this->fs->statSync($this->filePath);
  164. $functions = [
  165. JsFunction::createWithParameters(['fs', 'fileStats' => $fileStats])
  166. ->body("return fileStats.isFile();"),
  167. JsFunction::createWithScope(['fileStats' => $fileStats])
  168. ->body("return fileStats.isFile();"),
  169. ];
  170. foreach ($functions as $function) {
  171. $isFile = $this->fs->runCallback($function);
  172. $this->assertTrue($isFile);
  173. }
  174. }
  175. /**
  176. * @test
  177. * @group js-functions
  178. */
  179. public function can_use_async_with_js_functions()
  180. {
  181. $function = JsFunction::createWithAsync()
  182. ->body("
  183. await Promise.resolve();
  184. return true;
  185. ");
  186. $this->assertTrue($this->fs->runCallback($function));
  187. $function = $function->async(false);
  188. $this->expectException(Node\FatalException::class);
  189. $this->expectExceptionMessage('await is only valid in async function');
  190. $this->fs->runCallback($function);
  191. }
  192. /**
  193. * @test
  194. * @group js-functions
  195. */
  196. public function js_functions_are_sync_by_default()
  197. {
  198. $function = JsFunction::createWithBody('await null');
  199. $this->expectException(Node\FatalException::class);
  200. $this->expectExceptionMessage('await is only valid in async function');
  201. $this->fs->runCallback($function);
  202. }
  203. /** @test */
  204. public function can_receive_heavy_payloads_with_non_ascii_chars()
  205. {
  206. $payload = $this->fs->getHeavyPayloadWithNonAsciiChars();
  207. $this->assertStringStartsWith('😘', $payload);
  208. $this->assertStringEndsWith('😘', $payload);
  209. }
  210. /**
  211. * @test
  212. * @expectedException \Nesk\Rialto\Exceptions\Node\FatalException
  213. * @expectedExceptionMessage Object.__inexistantMethod__ is not a function
  214. */
  215. public function node_crash_throws_a_fatal_exception()
  216. {
  217. $this->fs->__inexistantMethod__();
  218. }
  219. /**
  220. * @test
  221. * @expectedException \Nesk\Rialto\Exceptions\Node\Exception
  222. * @expectedExceptionMessage Object.__inexistantMethod__ is not a function
  223. */
  224. public function can_catch_errors()
  225. {
  226. $this->fs->tryCatch->__inexistantMethod__();
  227. }
  228. /**
  229. * @test
  230. * @expectedException \Nesk\Rialto\Exceptions\Node\FatalException
  231. * @expectedExceptionMessage Object.__inexistantMethod__ is not a function
  232. */
  233. public function catching_a_node_exception_doesnt_catch_fatal_exceptions()
  234. {
  235. try {
  236. $this->fs->__inexistantMethod__();
  237. } catch (Node\Exception $exception) {
  238. //
  239. }
  240. }
  241. /**
  242. * @test
  243. * @dontPopulateProperties fs
  244. */
  245. public function in_debug_mode_node_exceptions_contain_stack_trace_in_message()
  246. {
  247. $this->fs = new FsWithProcessDelegation(['debug' => true]);
  248. $regex = '/\n\nError: "Object\.__inexistantMethod__ is not a function"\n\s+at /';
  249. try {
  250. $this->fs->tryCatch->__inexistantMethod__();
  251. } catch (Node\Exception $exception) {
  252. $this->assertRegExp($regex, $exception->getMessage());
  253. }
  254. try {
  255. $this->fs->__inexistantMethod__();
  256. } catch (Node\FatalException $exception) {
  257. $this->assertRegExp($regex, $exception->getMessage());
  258. }
  259. }
  260. /** @test */
  261. public function node_current_working_directory_is_the_same_as_php()
  262. {
  263. $result = $this->fs->accessSync('tests/resources/file');
  264. $this->assertNull($result);
  265. }
  266. /**
  267. * @test
  268. * @expectedException \Symfony\Component\Process\Exception\ProcessFailedException
  269. * @expectedExceptionMessageRegExp /Error Output:\n=+\n.*__inexistant_process__.*not found/
  270. */
  271. public function executable_path_option_changes_the_process_prefix()
  272. {
  273. new FsWithProcessDelegation(['executable_path' => '__inexistant_process__']);
  274. }
  275. /**
  276. * @test
  277. * @dontPopulateProperties fs
  278. */
  279. public function idle_timeout_option_closes_node_once_timer_is_reached()
  280. {
  281. $this->fs = new FsWithProcessDelegation(['idle_timeout' => 0.5]);
  282. $this->fs->constants;
  283. sleep(1);
  284. $this->expectException(\Nesk\Rialto\Exceptions\IdleTimeoutException::class);
  285. $this->expectExceptionMessageRegExp('/^The idle timeout \(0\.500 seconds\) has been exceeded/');
  286. $this->fs->constants;
  287. }
  288. /**
  289. * @test
  290. * @dontPopulateProperties fs
  291. * @expectedException \Nesk\Rialto\Exceptions\ReadSocketTimeoutException
  292. * @expectedExceptionMessageRegExp /^The timeout \(0\.010 seconds\) has been exceeded/
  293. */
  294. public function read_timeout_option_throws_an_exception_on_long_actions()
  295. {
  296. $this->fs = new FsWithProcessDelegation(['read_timeout' => 0.01]);
  297. $this->fs->wait(20);
  298. }
  299. /**
  300. * @test
  301. * @group logs
  302. * @dontPopulateProperties fs
  303. */
  304. public function forbidden_options_are_removed()
  305. {
  306. $this->fs = new FsWithProcessDelegation([
  307. 'logger' => $this->loggerMock(
  308. $this->at(0),
  309. $this->isLogLevel(),
  310. 'Applying options...',
  311. $this->callback(function ($context) {
  312. $this->assertArrayHasKey('read_timeout', $context['options']);
  313. $this->assertArrayNotHasKey('stop_timeout', $context['options']);
  314. $this->assertArrayNotHasKey('foo', $context['options']);
  315. return true;
  316. })
  317. ),
  318. 'read_timeout' => 5,
  319. 'stop_timeout' => 0,
  320. 'foo' => 'bar',
  321. ]);
  322. }
  323. /**
  324. * @test
  325. * @dontPopulateProperties fs
  326. */
  327. public function connection_delegate_receives_options()
  328. {
  329. $this->fs = new FsWithProcessDelegation([
  330. 'log_node_console' => true,
  331. 'new_option' => false,
  332. ]);
  333. $this->assertNull($this->fs->getOption('read_timeout')); // Assert this option is stripped by the supervisor
  334. $this->assertTrue($this->fs->getOption('log_node_console'));
  335. $this->assertFalse($this->fs->getOption('new_option'));
  336. }
  337. /**
  338. * @test
  339. * @dontPopulateProperties fs
  340. */
  341. public function process_status_is_tracked()
  342. {
  343. if (PHP_OS === 'WINNT') {
  344. $this->markTestSkipped('This test is not supported on Windows.');
  345. }
  346. if ((new Process(['which', 'pgrep']))->run() !== 0) {
  347. $this->markTestSkipped('The "pgrep" command is not available.');
  348. }
  349. $oldPids = $this->getPidsForProcessName('node');
  350. $this->fs = new FsWithProcessDelegation;
  351. $newPids = $this->getPidsForProcessName('node');
  352. $newNodeProcesses = array_values(array_diff($newPids, $oldPids));
  353. $newNodeProcessesCount = count($newNodeProcesses);
  354. $this->assertCount(
  355. 1,
  356. $newNodeProcesses,
  357. "One Node process should have been created instead of $newNodeProcessesCount. Try running again."
  358. );
  359. $processKilled = posix_kill($newNodeProcesses[0], SIGKILL);
  360. $this->assertTrue($processKilled);
  361. $this->expectException(\Nesk\Rialto\Exceptions\ProcessUnexpectedlyTerminatedException::class);
  362. $this->expectExceptionMessage('The process has been unexpectedly terminated.');
  363. $this->fs->foo;
  364. }
  365. /** @test */
  366. public function process_is_properly_shutdown_when_there_are_no_more_references()
  367. {
  368. if (!class_exists('WeakRef')) {
  369. $this->markTestSkipped(
  370. 'This test requires weak references (unavailable for PHP 7.3): http://php.net/weakref/'
  371. );
  372. }
  373. $ref = new \WeakRef($this->fs->getProcessSupervisor());
  374. $resource = $this->fs->readFileSync($this->filePath);
  375. $this->assertInstanceOf(BasicResource::class, $resource);
  376. $this->fs = null;
  377. unset($resource);
  378. $this->assertFalse($ref->valid());
  379. }
  380. /**
  381. * @test
  382. * @group logs
  383. * @dontPopulateProperties fs
  384. */
  385. public function logger_is_used_when_provided()
  386. {
  387. $this->fs = new FsWithProcessDelegation([
  388. 'logger' => $this->loggerMock(
  389. $this->atLeastOnce(),
  390. $this->isLogLevel(),
  391. $this->isType('string')
  392. ),
  393. ]);
  394. }
  395. /**
  396. * @test
  397. * @group logs
  398. * @dontPopulateProperties fs
  399. */
  400. public function node_console_calls_are_logged()
  401. {
  402. $setups = [
  403. [false, 'Received data on stdout:'],
  404. [true, 'Received a Node log:'],
  405. ];
  406. foreach ($setups as [$logNodeConsole, $startsWith]) {
  407. $this->fs = new FsWithProcessDelegation([
  408. 'log_node_console' => $logNodeConsole,
  409. 'logger' => $this->loggerMock(
  410. $this->at(5),
  411. $this->isLogLevel(),
  412. $this->stringStartsWith($startsWith)
  413. ),
  414. ]);
  415. $this->fs->runCallback(JsFunction::createWithBody("console.log('Hello World!')"));
  416. }
  417. }
  418. /**
  419. * @test
  420. * @group logs
  421. * @dontPopulateProperties fs
  422. */
  423. public function delayed_node_console_calls_and_data_on_standard_streams_are_logged()
  424. {
  425. $this->fs = new FsWithProcessDelegation([
  426. 'log_node_console' => true,
  427. 'logger' => $this->loggerMock([
  428. [$this->at(6), $this->isLogLevel(), $this->stringStartsWith('Received data on stdout:')],
  429. [$this->at(7), $this->isLogLevel(), $this->stringStartsWith('Received a Node log:')],
  430. ]),
  431. ]);
  432. $this->fs->runCallback(JsFunction::createWithBody("
  433. setTimeout(() => {
  434. process.stdout.write('Hello Stdout!');
  435. console.log('Hello Console!');
  436. });
  437. "));
  438. usleep(10000); // 10ms, to be sure the delayed instructions just above are executed.
  439. $this->fs = null;
  440. }
  441. }