/ src / library / JavascriptUnitTest.php
<?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()
    {
    }
}
X