diff --git a/.github/workflows/upstream-compatibility.yml b/.github/workflows/upstream-compatibility.yml new file mode 100644 index 0000000..5c2fb12 --- /dev/null +++ b/.github/workflows/upstream-compatibility.yml @@ -0,0 +1,47 @@ +name: Upstream Compatibility + +on: + pull_request: + paths: + - ".github/workflows/upstream-compatibility.yml" + - "tests/upstream-compat.php" + workflow_dispatch: + schedule: + - cron: "17 4 * * 1" + +permissions: + contents: read + +jobs: + upstream-corpus: + runs-on: ubuntu-latest + + steps: + - name: Checkout parser + uses: actions/checkout@v7 + with: + path: parser + + - name: Checkout upstream eXeLearning fixtures + uses: actions/checkout@v7 + with: + repository: exelearning/exelearning + path: upstream + sparse-checkout: | + test/fixtures + sparse-checkout-cone-mode: false + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + extensions: dom, libxml, simplexml, zip + coverage: none + + - name: Install dependencies + working-directory: parser + run: composer update --prefer-stable --prefer-dist --no-interaction + + - name: Parse upstream project corpus + working-directory: parser + run: php tests/upstream-compat.php ../upstream/test/fixtures diff --git a/README.md b/README.md index 8b6f82a..47e557a 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,18 @@ The parser distinguishes the internal project format from the detected eXeLearni Use `getFormatVersion()` for the ODE format version, `getApplicationVersion()` for the declared eXeLearning version, and `getVersionInfo()` when the distinction between declared and inferred versions matters. +## Compatibility regression testing + +The regular test suite includes a deterministic corpus for malformed XML, encoded and Unicode asset paths, malformed iDevice state and cyclic page hierarchies. + +A separate `Upstream Compatibility` workflow runs weekly against project fixtures from `exelearning/exelearning`. It discovers ZIP-backed `.elp` / `.elpx` fixtures containing `content.xml` or `contentv3.xml`, compares lightweight inspection with full parsing, and fails on compatibility regressions. + +The corpus runner can also be used locally: + +```bash +php tests/upstream-compat.php /path/to/exelearning/test/fixtures +``` + ## License The project is distributed under the MIT License. See [LICENSE.md](LICENSE.md). diff --git a/tests/Unit/RobustnessRegressionTest.php b/tests/Unit/RobustnessRegressionTest.php new file mode 100644 index 0000000..94e9346 --- /dev/null +++ b/tests/Unit/RobustnessRegressionTest.php @@ -0,0 +1,195 @@ + + * @license MIT https://opensource.org/licenses/MIT + * @link https://github.com/exelearning/elp-parser + */ + +namespace Exelearning\ElpParser\Tests\Unit; + +use Exelearning\Asset\AssetReferenceExtractor; +use Exelearning\ELPParser; +use Exelearning\Exception\InvalidXmlException; +use Exelearning\Parser\IdeviceStateParser; +use RuntimeException; +use ZipArchive; + +/** + * Create a temporary ZIP-backed project with custom content.xml. + * + * @param string $xml Project XML. + * @param array $entries Additional archive entries. + * + * @return string + */ +function createRegressionArchive(string $xml, array $entries = []): string +{ + $temporaryFile = tempnam(sys_get_temp_dir(), 'elp-regression-'); + if ($temporaryFile === false) { + throw new RuntimeException('Unable to create temporary file.'); + } + + @unlink($temporaryFile); + $archivePath = $temporaryFile . '.elpx'; + + $zip = new ZipArchive(); + if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Unable to create temporary archive.'); + } + + $zip->addFromString('content.xml', $xml); + + foreach ($entries as $path => $contents) { + $zip->addFromString($path, $contents); + } + + $zip->close(); + + return $archivePath; +} + +it( + 'rejects a deterministic corpus of malformed xml documents', + function () { + $corpus = [ + '', + '', + '', + '&unknown;', + ]; + + foreach ($corpus as $xml) { + $archive = createRegressionArchive($xml); + + try { + expect(fn() => ELPParser::fromFile($archive)) + ->toThrow(InvalidXmlException::class); + } finally { + @unlink($archive); + } + } + } +); + +it( + 'handles unicode query fragments url encoding and traversal-like asset references', + function () { + $extractor = new AssetReferenceExtractor( + [ + 'content/resources/Imágen ñ.jpg', + 'content/resources/manual.pdf', + ] + ); + + $pages = [ + [ + 'id' => 'PAGE', + 'title' => 'Page', + 'idevices' => [ + [ + 'id' => 'IDEVICE', + 'type' => 'text', + 'html' => '' + . 'Manual' + . 'External' + . 'Traversal', + 'jsonProperties' => [], + 'data' => [], + ], + ], + ], + ]; + + $resolved = array_column($extractor->extract($pages), 'path'); + $broken = array_column($extractor->findBrokenReferences($pages), 'reference'); + + expect($resolved)->toContain('content/resources/Imágen ñ.jpg'); + expect($resolved)->toContain('content/resources/manual.pdf'); + expect($broken)->toContain('../../secret.pdf'); + expect($broken)->not->toContain('https://example.com/external.pdf'); + } +); + +it( + 'keeps malformed idevice state isolated for every structured storage pattern', + function () { + $parser = new IdeviceStateParser(); + + $cases = [ + [ + '
%7Bbroken
', + '', + IdeviceStateParser::PATTERN_DATA_GAME, + ], + [ + '', + '', + IdeviceStateParser::PATTERN_EMBEDDED_JSON, + ], + [ + '

Standard

', + '{broken', + IdeviceStateParser::PATTERN_STANDARD_JSON, + ], + ]; + + foreach ($cases as [$html, $json, $pattern]) { + $state = $parser->parse($html, $json); + + expect($state['storagePattern'])->toBe($pattern); + expect($state['data'])->toBe([]); + expect($state['decodeError'])->toBeString(); + } + } +); + +it( + 'reports cyclic page hierarchies without hanging', + function () { + $xml = <<<'XML' + + + + exe_version4.0.0 + + + + + PAGE-A + PAGE-B + 1 + A + + + + + PAGE-B + PAGE-A + 1 + B + + + + + +XML; + + $archive = createRegressionArchive($xml); + + try { + $parser = ELPParser::fromFile($archive); + $result = $parser->validate(); + + expect(array_column($result['errors'], 'code')) + ->toContain('page_hierarchy_cycle'); + expect($parser->getPageTree())->toBe([]); + } finally { + @unlink($archive); + } + } +); diff --git a/tests/upstream-compat.php b/tests/upstream-compat.php new file mode 100644 index 0000000..75da2a2 --- /dev/null +++ b/tests/upstream-compat.php @@ -0,0 +1,111 @@ +isFile()) { + continue; + } + + $extension = strtolower($file->getExtension()); + if (!in_array($extension, ['elp', 'elpx'], true)) { + continue; + } + + $candidates[] = $file->getPathname(); +} + +sort($candidates); + +$parsed = 0; +$skipped = 0; +$failures = []; + +foreach ($candidates as $path) { + $zip = new ZipArchive(); + if ($zip->open($path) !== true) { + $skipped++; + continue; + } + + $hasProjectXml = $zip->locateName('content.xml') !== false + || $zip->locateName('contentv3.xml') !== false; + $zip->close(); + + if (!$hasProjectXml) { + $skipped++; + continue; + } + + try { + $inspection = ELPParser::inspect($path); + $parser = ELPParser::fromFile($path); + + if (($inspection['title'] ?? '') !== $parser->getTitle()) { + throw new RuntimeException( + 'Lightweight inspection and full parsing returned different titles.' + ); + } + + $parsed++; + fwrite( + STDOUT, + sprintf( + "PASS %s [%s]\n", + substr($path, strlen(rtrim($root, DIRECTORY_SEPARATOR)) + 1), + $parser->getPackageProfile() + ) + ); + } catch (Throwable $exception) { + $failures[] = [ + 'path' => $path, + 'message' => $exception->getMessage(), + 'class' => get_class($exception), + ]; + + fwrite( + STDERR, + sprintf( + "FAIL %s: %s: %s\n", + $path, + get_class($exception), + $exception->getMessage() + ) + ); + } +} + +fwrite( + STDOUT, + sprintf( + "\nUpstream corpus: %d candidates, %d parsed, %d skipped, %d failures.\n", + count($candidates), + $parsed, + $skipped, + count($failures) + ) +); + +exit($failures === [] ? 0 : 1);