<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\library;
use seekquarry\yioop\configs as C;
/**
* Super class of all the test classes testing Javascript functions.
*
* @author Akash Patel
*/
class JavascriptUnitTest extends UnitTest
{
/**
* {@inheritDoc}
*/
public function setUp()
{
}
/**
* Runs the test cases of this test. In a browser each case returns a
* block of html and javascript that checks itself and shows a pass or
* fail table, and this method hands those blocks back for the page to
* render. From the command line there is no browser, so when node is
* installed this method instead replays each block under node and
* turns its result into ordinary pass or fail assertions, letting the
* command line test runner report javascript tests the same way it
* reports php ones.
*
* @param string $method name of a single case to run, or null for all
* @return array test case results; for a browser these are the html
* blocks keyed by case name, and from the command line under node
* they are the usual list of sub-case results paired with the
* time each case took
*/
public function run($method = null)
{
$node = $this->nodeCommand();
if (php_sapi_name() != "cli" || $node == "") {
return $this->runInBrowser($method);
}
$test_results = [];
$methods = ($method == null) ? get_class_methods(get_class($this)) :
[$method];
foreach ($methods as $case_method) {
$length = strlen($case_method);
if (substr_compare($case_method, self::case_name,
$length - strlen(self::case_name)) != 0) {
continue;
}
$this->test_objects = null;
$this->setUp();
$this->test_case_results = [];
$start_time = microtime(true);
try {
$case_output = $this->$case_method();
$case_results = $this->runJavascriptCase($node,
$case_output);
foreach ($case_results as $case_result) {
$this->assertTrue($case_result["pass"],
$case_result["name"]);
}
} finally {
$test_time = changeInMicrotime($start_time);
$this->tearDown();
}
$test_results[$case_method] =
[$this->test_case_results, $test_time];
}
return $test_results;
}
/**
* Collects the html and javascript block each test case produces so a
* browser can render it and run the check on the page. This is the
* behaviour used whenever a browser is present.
*
* @param string $method name of a single case to run, or null for all
* @return array the html blocks produced by the cases, keyed by the
* case method name
*/
protected function runInBrowser($method = null)
{
$test_results = [];
$methods = ($method == null) ? get_class_methods(get_class($this)) :
[$method];
foreach ($methods as $case_method) {
$this->test_objects = null;
$this->setUp();
$length = strlen($case_method);
if (substr_compare($case_method, self::case_name,
$length - strlen(self::case_name)) == 0) {
$test_results[$case_method] = $this->$case_method();
}
$this->tearDown();
}
return $test_results;
}
/**
* Finds a command that runs node, the javascript engine used to check
* javascript tests without a browser. It tries the usual two names and
* returns whichever answers, or the empty string when neither does so
* the caller can fall back to the browser only path.
*
* @return string the node command that works, or an empty string when
* node is not installed
*/
public function nodeCommand()
{
foreach (["node", "nodejs"] as $candidate) {
$version_lines = [];
$exit_code = 1;
@exec(escapeshellarg($candidate) . " --version 2>/dev/null",
$version_lines, $exit_code);
if ($exit_code === 0) {
return $candidate;
}
}
return "";
}
/**
* Replays one test case's html and javascript block under node and
* reads back a result for each case it checked. It pulls the browser
* scripts and inline script out of the block, loads each named script
* once, and wraps the whole thing with stand-in page objects so it runs
* without a browser. A test reports its cases by calling
* reportUnitTestResults, which the epilogue reads back; a test that does
* not do this still yields a count parsed from an "n/m Test Passed"
* cell.
*
* @param string $node the working node command from nodeCommand
* @param string $html the html and javascript a test case returned
* @return array a list of results, each an array with a name string
* under the key name and a pass boolean under the key pass
*/
protected function runJavascriptCase($node, $html)
{
preg_match_all('/<script[^>]*\bsrc\s*=\s*"([^"]+)"/i', $html,
$script_sources);
preg_match_all(
'/<script(?![^>]*\bsrc)[^>]*>(.*?)<\/script>/is', $html,
$inline_scripts);
$program = $this->nodePreamble() . "\n";
$loaded = [];
foreach ($script_sources[1] as $source_url) {
$file_name = preg_replace('#^.*/scripts/#', '', $source_url);
if (isset($loaded[$file_name])) {
continue;
}
$loaded[$file_name] = true;
$file_path = C\BASE_DIR . "/scripts/" . $file_name;
if (is_file($file_path)) {
$program .= file_get_contents($file_path) . "\n";
}
}
foreach ($inline_scripts[1] as $inline_body) {
$program .= $inline_body . "\n";
}
$program .= $this->nodeEpilogue();
$program_path = tempnam(sys_get_temp_dir(), "jsunit") . ".js";
file_put_contents($program_path, $program);
$output_lines = [];
$exit_code = 1;
@exec(escapeshellarg($node) . " " . escapeshellarg($program_path) .
" 2>&1", $output_lines, $exit_code);
@unlink($program_path);
$output = implode("\n", $output_lines);
$marker = "UNIT_TEST_RESULTS_JSON:";
$position = strpos($output, $marker);
if ($position !== false) {
$json = substr($output, $position + strlen($marker));
$decoded = json_decode(trim($json), true);
if (is_array($decoded)) {
return $this->normalizeResults($decoded);
}
}
return [["name" => "node produced no verdict: " . $output,
"pass" => false]];
}
/**
* Cleans up the list of results node handed back so each entry has a
* name and a pass flag, and makes sure there is at least one entry so a
* silent test still shows up as a failure rather than vanishing.
*
* @param array $decoded the list of result objects parsed from node
* @return array the tidied list of name and pass entries
*/
protected function normalizeResults($decoded)
{
$results = [];
foreach ($decoded as $entry) {
if (is_array($entry) && isset($entry["pass"])) {
$name = isset($entry["name"]) ? $entry["name"] : "case";
$results[] = ["name" => $name,
"pass" => (bool)$entry["pass"]];
}
}
if ($results === []) {
$results[] = ["name" => "no cases were reported",
"pass" => false];
}
return $results;
}
/**
* Builds the stand-in page objects the browser scripts expect, so they
* load and run under node. The stand-in table cells record the text
* assigned to them into a list the epilogue reads to learn the result.
*
* @return string the javascript that sets up the stand-in page objects
*/
protected function nodePreamble()
{
$lines = [
'var __captured = [];',
'class Element {',
' constructor() {',
' this.classList = { add() {} };',
' this.style = {};',
' }',
' set innerHTML(value) {',
' __captured.push(String(value));',
' }',
' get innerHTML() { return ""; }',
' set className(value) {}',
' setAttribute() {}',
' appendChild() {}',
' insertRow() { return new Element(); }',
' insertCell() { return new Element(); }',
'}',
'var window = { addEventListener() {}, innerWidth: 0,',
' innerHeight: 0, location: { href: "" } };',
'var document = { addEventListener() {},',
' getElementById() { return new Element(); },',
' getElementsByTagName() { return []; },',
' createElement() { return new Element(); } };',
'var navigator = { userAgent: "node" };',
'var location = { href: "" };',
];
return implode("\n", $lines);
}
/**
* Builds the javascript that reads back the results the test recorded
* and prints them as one json line the php side can parse. A test that
* called reportUnitTestResults leaves a list this reads directly; a
* test that only wrote an "n/m Test Passed" cell is turned into that
* many pass or fail entries so its count still shows.
*
* @return string the javascript that prints the json results line
*/
protected function nodeEpilogue()
{
$lines = [
'(function () {',
' var list = [];',
' if (typeof unit_test_results !== "undefined" &&',
' unit_test_results.length > 0) {',
' for (var index = 0; index < unit_test_results.length;',
' index++) {',
' var entry = unit_test_results[index];',
' list.push({ name: String(entry.name),',
' pass: entry.pass ? true : false });',
' }',
' } else {',
' var joined = __captured.join(" ");',
' var pattern = /(\d+)\s*\/\s*(\d+)\s*Test\s*Passed/i;',
' var match = joined.match(pattern);',
' if (match) {',
' var done = parseInt(match[1], 10);',
' var total = parseInt(match[2], 10);',
' for (var number = 0; number < total; number++) {',
' list.push({ name: "case " + (number + 1),',
' pass: number < done });',
' }',
' }',
' }',
' process.stdout.write("UNIT_TEST_RESULTS_JSON:" +',
' JSON.stringify(list));',
'})();',
];
return implode("\n", $lines);
}
/**
* {@inheritDocs}
*/
public function tearDown()
{
}
}