packages/sql-catalog/tests/Unit/Reporter/Html/Page/FilePageTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Reporter\Html\Page;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use SqlCatalog\Core\Catalog\AnalysisProblem;
11use SqlCatalog\Core\Catalog\CallSite;
12use SqlCatalog\Core\Catalog\Catalog;
13use SqlCatalog\Core\Catalog\CatalogEntry;
14use SqlCatalog\Core\Catalog\Finding;
15use SqlCatalog\Core\Catalog\FindingRule;
16use SqlCatalog\Core\Catalog\Placeholder;
17use SqlCatalog\Core\Catalog\Resolution;
18use SqlCatalog\Core\Catalog\Severity;
19use SqlCatalog\Core\Catalog\StatementPart;
20use SqlCatalog\Core\Catalog\ValueDomain;
21use SqlCatalog\Core\Sql\StatementKind;
22use SqlCatalog\Core\Text\LiteralText;
23use SqlCatalog\Core\Text\Origin;
24use SqlCatalog\Core\Text\TextHole;
25use SqlCatalog\Core\Text\TextPattern;
26use SqlCatalog\Core\Type\TypeShape;
27use SqlCatalog\Reporter\Html\CatalogIndex;
28use SqlCatalog\Reporter\Html\CatalogStatistics;
29use SqlCatalog\Reporter\Html\HtmlText;
30use SqlCatalog\Reporter\Html\Page\FilePage;
31use SqlCatalog\Reporter\Html\Palette;
32use SqlCatalog\Reporter\Html\ReportSite;
33use SqlCatalog\Reporter\Html\Scope;
34use SqlCatalog\Reporter\Html\Source\SourceCode;
35use SqlCatalog\Reporter\Html\SqlFormatter;
36use SqlCatalog\Reporter\Html\SqlHighlighter;
37use SqlCatalog\Reporter\Html\StatementList;
38use SqlCatalog\Reporter\Html\StatementRow;
39use SqlCatalog\Reporter\Html\TableName;
40
41#[CoversClass(FilePage::class)]
42#[UsesClass(CallSite::class)]
43#[UsesClass(Catalog::class)]
44#[UsesClass(CatalogEntry::class)]
45#[UsesClass(CatalogIndex::class)]
46#[UsesClass(CatalogStatistics::class)]
47#[UsesClass(HtmlText::class)]
48#[UsesClass(Palette::class)]
49#[UsesClass(ReportSite::class)]
50#[UsesClass(Resolution::class)]
51#[UsesClass(Scope::class)]
52#[UsesClass(SourceCode::class)]
53#[UsesClass(Severity::class)]
54#[UsesClass(SqlHighlighter::class)]
55#[UsesClass(StatementKind::class)]
56#[UsesClass(StatementList::class)]
57#[UsesClass(StatementPart::class)]
58#[UsesClass(StatementRow::class)]
59#[UsesClass(TableName::class)]
60#[UsesClass(LiteralText::class)]
61#[UsesClass(TextPattern::class)]
62#[UsesClass(AnalysisProblem::class)]
63#[UsesClass(Finding::class)]
64#[UsesClass(FindingRule::class)]
65#[UsesClass(Placeholder::class)]
66#[UsesClass(ValueDomain::class)]
67#[UsesClass(SqlFormatter::class)]
68#[UsesClass(Origin::class)]
69#[UsesClass(TextHole::class)]
70#[UsesClass(TypeShape::class)]
71final class FilePageTest extends TestCase
72{
73 public function testRenderListsTheStatementsFunctionByFunction(): void
74 {
75 $catalog = new Catalog([
76 new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), ['users'], [], new CallSite('src/a.php', 9, 'helper', 'pdo.query'), []),
77 new CatalogEntry('a2', StatementKind::Select, TextPattern::fromText('SELECT 2'), [], [], new CallSite('src/a.php', 1, '{main}', 'pdo.query'), []),
78 ]);
79 $page = (new FilePage())->render(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), 'src/a.php');
80
81 self::assertStringContainsString('<h1><code>src/a.php</code><span class="count">2 statements</span></h1>', $page);
82 self::assertStringContainsString('2 statements issued from 2 functions in this file.', $page);
83 self::assertMatchesRegularExpression('/id="fn-main".*id="fn-helper"/s', $page);
84 }
85
86 public function testTablesLinkToTheTablesNamed(): void
87 {
88 $entry = new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), ['users'], [], new CallSite('a.php', 1, 'f', 'pdo.query'), []);
89
90 self::assertSame(
91 '<h2 id="tables">Tables</h2><div class="chips"><a class="chip chip-ghost" href="../tables/users.html">users<span class="facet-count">1</span></a></div>',
92 (new FilePage())->tables(new ReportSite(new Catalog([$entry]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), [$entry]),
93 );
94 }
95
96 public function testFunctionsAreInTheOrderTheyAreWritten(): void
97 {
98 $entries = [
99 new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 9, 'later', 'pdo.query'), []),
100 new CatalogEntry('a2', StatementKind::Select, TextPattern::fromText('SELECT 2'), [], [], new CallSite('a.php', 3, 'earlier', 'pdo.query'), []),
101 ];
102
103 self::assertSame(['earlier', 'later'], array_keys((new FilePage())->functions($entries)));
104 }
105
106 public function testSectionsLeadFromAMethodToItsClass(): void
107 {
108 $entry = new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 9, 'App\\R::find', 'pdo.query'), []);
109 $site = new ReportSite(new Catalog([$entry]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
110
111 self::assertStringStartsWith(
112 '<section class="group" id="fn-app-r-find"><h3><a class="mono" href="../classes/app-r.html">R::find</a><span class="count">1</span><span class="muted">line 9</span>',
113 (new FilePage())->sections($site, 'a.php', ['App\\R::find' => [$entry]]),
114 );
115 }
116
117 public function testAnchorsNameTheTablesAndEveryFunction(): void
118 {
119 $entry = new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 9, '{main}', 'pdo.query'), []);
120
121 self::assertSame([['Tables', 'tables'], ['top-level code', 'fn-main']], (new FilePage())->anchors(new ReportSite(new Catalog([$entry]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), [$entry]));
122 }
123
124
125 public function testContextListsTheFunctionsAndTheFilesOfTheSameDirectory(): void
126 {
127 $catalog = new Catalog([
128 new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('src/a.php', 1, 'f', 'pdo.query'), []),
129 new CatalogEntry('b1', StatementKind::Select, TextPattern::fromText('SELECT 2'), [], [], new CallSite('src/b.php', 1, 'g', 'pdo.query'), []),
130 new CatalogEntry('c1', StatementKind::Select, TextPattern::fromText('SELECT 3'), [], [], new CallSite('lib/c.php', 1, 'h', 'pdo.query'), []),
131 ]);
132
133 self::assertSame(
134 [
135 ['On this page', [['Tables', '#tables', null, false], ['f', '#fn-f', null, false]], null],
136 ['Files in src/', [['a.php', 'files/src-a-php.html', 1, true], ['b.php', 'files/src-b-php.html', 1, false]], 'files.html'],
137 ],
138 (new FilePage())->context(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), 'src/a.php'),
139 );
140 }
141
142 public function testContextNamesTheRootDirectory(): void
143 {
144 $catalog = new Catalog([
145 new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('index.php', 1, '{main}', 'pdo.query'), []),
146 ]);
147
148 self::assertSame(
149 ['Files in (root)', [['index.php', 'files/index-php.html', 1, true]], 'files.html'],
150 (new FilePage())->context(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), 'index.php')[1],
151 );
152 }
153
154 public function testRenderIsWrittenExactly(): void
155 {
156 $entries = [
157 new CatalogEntry('a1', StatementKind::Select, TextPattern::fromSegments([
158 new LiteralText('SELECT id FROM posts WHERE slug = '),
159 new TextHole(Origin::External, TypeShape::unknown(), '$_GET["s"]'),
160 ]), ['posts'], [new Placeholder('?', 0, null, new ValueDomain('int', [7], true, []))], new CallSite('src/a.php', 4, 'App\\R::find', 'pdo.query'), [
161 Finding::of(FindingRule::ExternalInput, 'spliced'),
162 ], false, ['App\\R::run', 'App\\R::find']),
163 new CatalogEntry('a2', StatementKind::Insert, TextPattern::fromText('INSERT INTO posts (id) VALUES (1)'), ['posts'], [], new CallSite('src/a.php', 9, 'App\\R::add', 'pdo.query'), []),
164 new CatalogEntry('a3', StatementKind::Update, TextPattern::fromText('UPDATE posts SET title = ? WHERE id = ?'), ['posts'], [], new CallSite('src/a.php', 14, 'App\\R::add', 'pdo.prepare'), [
165 Finding::of(FindingRule::PlaceholderCountMismatch, 'one bound'),
166 ]),
167 new CatalogEntry('b1', StatementKind::Delete, TextPattern::fromText('DELETE FROM users WHERE id = 1'), ['users'], [], new CallSite('src/b.php', 3, 'App\\Admin\\U::drop', 'pdo.query'), []),
168 new CatalogEntry('b2', StatementKind::Alter, TextPattern::fromText('ALTER TABLE users ADD x INT'), ['users'], [], new CallSite('src/b.php', 8, 'App\\Admin\\U::migrate', 'pdo.exec'), []),
169 new CatalogEntry('c1', StatementKind::Select, TextPattern::fromHole(new TextHole(Origin::Budget, TypeShape::unknown())), [], [], new CallSite('lib/c.php', 2, 'helper', 'mysqli.query'), [
170 Finding::of(FindingRule::AnalysisIncomplete, 'stopped'),
171 ], true, [], true),
172 new CatalogEntry('c2', StatementKind::Unknown, TextPattern::fromHole(new TextHole(Origin::Unreached, TypeShape::unknown(), '$db->query($sql)')), [], [], new CallSite('lib/c.php', 6, '{main}', 'unmatched'), [
173 Finding::of(FindingRule::CallNotAnalyzed, 'unseen'),
174 ]),
175 new CatalogEntry('d1', StatementKind::Select, TextPattern::fromText('SELECT * FROM posts p JOIN users u ON u.id = p.author'), ['posts', 'users'], [], new CallSite('src/d.php', 1, 'App\\R::find', 'pdo.query'), [
176 Finding::of(FindingRule::DynamicSql, 'dyn'),
177 ]),
178 new CatalogEntry('e1', StatementKind::Show, TextPattern::fromText('SHOW TABLES'), [], [], new CallSite('src/e.php', 1, 'App\\R::find', 'pdo.query'), []),
179 new CatalogEntry('f1', StatementKind::Select, TextPattern::fromText('SELECT 1 FROM t1'), ['t1'], [], new CallSite('src/f.php', 1, 'App\\F::a', 'pdo.query'), []),
180 new CatalogEntry('f2', StatementKind::Select, TextPattern::fromText('SELECT 2 FROM t2'), ['t2'], [], new CallSite('src/f.php', 2, 'App\\F::b', 'pdo.query'), []),
181 new CatalogEntry('f3', StatementKind::Select, TextPattern::fromText('SELECT 3 FROM t3'), ['t3'], [], new CallSite('src/f.php', 3, 'App\\G::a', 'pdo.query'), []),
182 new CatalogEntry('f4', StatementKind::Select, TextPattern::fromText('SELECT 4 FROM t4'), ['t4'], [], new CallSite('src/f.php', 4, 'App\\H::a', 'pdo.query'), []),
183 new CatalogEntry('f5', StatementKind::Select, TextPattern::fromText('SELECT 5 FROM t5'), ['t5'], [], new CallSite('src/g.php', 1, 'App\\I::a', 'pdo.query'), []),
184 new CatalogEntry('f6', StatementKind::Select, TextPattern::fromText('SELECT 6 FROM t6'), ['t6'], [], new CallSite('src/h.php', 1, 'App\\J::a', 'pdo.query'), []),
185 new CatalogEntry('f7', StatementKind::Select, TextPattern::fromText('SELECT 7 FROM t7'), ['t7'], [], new CallSite('src/i.php', 1, 'App\\K::a', 'pdo.query'), [
186 Finding::of(FindingRule::DynamicSql, 'dyn'),
187 ]),
188 new CatalogEntry('f8', StatementKind::Select, TextPattern::fromText('SELECT 8 FROM t8'), ['t8'], [], new CallSite('src/j.php', 1, 'App\\L::a', 'pdo.query'), [
189 Finding::of(FindingRule::DynamicSql, 'dyn'),
190 ]),
191 new CatalogEntry('f9', StatementKind::Select, TextPattern::fromText('SELECT 9 FROM t9'), ['t9'], [], new CallSite('src/k.php', 1, 'App\\M::a', 'pdo.query'), [
192 Finding::of(FindingRule::DynamicSql, 'dyn'),
193 ]),
194 ];
195 $catalog = new Catalog($entries, [new AnalysisProblem('src/broken.php', 'broken')]);
196 $site = new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
197
198 self::assertSame(
199 '<h1><code>lib/c.php</code><span class="count">2 statements</span></h1><p class="lede">2 statements issued from 2 functions in this file.</p>'
200 . '<h2 id="tables">Tables</h2><p class="empty-inline">No statement here names a table.</p><h2 id="functions">Functions</h2><div data-narrowable'
201 . '><div class="facets" role="group" aria-label="Narrow the listing"><input type="search" name="narrow" class="input facet-search" placeholder='
202 . '"Narrow by text…" aria-label="Narrow by text" autocomplete="off" spellcheck="false"><span class="facet-group"><button type="button" class='
203 . '"chip facet tone-blue" data-facet="kind" data-value="select" aria-pressed="false">SELECT<span class="facet-count">1</span></button><button t'
204 . 'ype="button" class="chip facet tone-slate" data-facet="kind" data-value="unknown" aria-pressed="false">UNKNOWN<span class="facet-count">1</s'
205 . 'pan></button></span><span class="facet-group"><button type="button" class="chip facet chip-ghost" data-facet="resolution" data-value="incomp'
206 . 'lete" aria-pressed="false">incomplete<span class="facet-count">1</span></button><button type="button" class="chip facet tone-neutral" data-f'
207 . 'acet="resolution" data-value="not-analyzed" aria-pressed="false">not-analyzed<span class="facet-count">1</span></button></span><span class="'
208 . 'facet-shown" aria-live="polite"></span><button type="button" class="btn facet-clear" hidden>Clear</button></div><section class="group" id="f'
209 . 'n-helper"><h3><code>helper</code><span class="count">1</span><span class="muted">line 2</span><a class="anchor" href="#fn-helper">#</a></h3>'
210 . '<ol class="rows"><li class="row" data-kind="select" data-resolution="incomplete" data-severity="low" data-rule="analysis-incomplete" data-si'
211 . 'nk="mysqli.query" data-open="open" data-table="" data-namespace="" data-class="" data-function="helper" data-file="lib/c.php"><a class="row-'
212 . 'main" href="../statements/c1.html"><span class="chip tone-blue">SELECT</span><pre class="code"><span class="hole tone-warn" title="This is a'
213 . ' gap: a dependency the analyzer stopped following fills it.">{$}</span></pre></a><p class="row-meta"><span class="chip chip-ghost" title="A '
214 . 'cycle or an analysis budget stopped the search before it closed.">incomplete</span></p></li></ol></section><section class="group" id="fn-mai'
215 . 'n"><h3><code>top-level code</code><span class="count">1</span><span class="muted">line 6</span><a class="anchor" href="#fn-main">#</a></h3><'
216 . 'ol class="rows"><li class="row" data-kind="unknown" data-resolution="not-analyzed" data-severity="low" data-rule="call-not-analyzed" data-si'
217 . 'nk="unmatched" data-open="open" data-table="" data-namespace="" data-class="" data-function="{main}" data-file="lib/c.php"><a class="row-mai'
218 . 'n" href="../statements/c2.html"><span class="chip tone-slate">UNKNOWN</span><pre class="code"><span class="tok-com">no statement was read fr'
219 . 'om this call</span> $db->query($sql)</pre></a><p class="row-meta"><span class="chip tone-neutral" title="The call was found but never exa'
220 . 'mined, so nothing was read from it.">not-analyzed</span></p></li></ol></section></div>',
221 (new FilePage())->render($site, 'lib/c.php'),
222 );
223 }
224
225 public function testProblemsExplainsWhyThisFileCouldNotBeParsed(): void
226 {
227 $site = new ReportSite(new Catalog([], [new AnalysisProblem('broken.php', 'Unexpected <token>'), new AnalysisProblem('other.php', 'Other problem'), new AnalysisProblem('broken.php', 'Another error')]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
228 $page = new FilePage();
229
230 self::assertSame(
231 '<div class="notice tone-warn"><p>This file could not be parsed.</p><ul><li>Unexpected <token></li><li>Another error</li></ul></div>',
232 $page->problems($site, 'broken.php'),
233 );
234 self::assertStringNotContainsString('Other problem', $page->problems($site, 'broken.php'));
235 self::assertSame('', $page->problems($site, 'valid.php'));
236 }
237
238
239 public function testRenderShowsTheParseErrorBeforeTheFileContents(): void
240 {
241 $site = new ReportSite(new Catalog([], [new AnalysisProblem('src/broken.php', 'Unexpected <token>')], ['src/broken.php' => '<?php function {']), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
242 $page = (new FilePage())->render($site, 'src/broken.php');
243
244 self::assertStringContainsString('in this file.</p><div class="notice tone-warn"><p>This file could not be parsed.</p>', $page);
245 self::assertStringContainsString('<li>Unexpected <token></li></ul></div><h2 id="tables">', $page);
246 self::assertStringContainsString('</div><section><h2 id="source">Source code</h2>', $page);
247 self::assertStringContainsString('</a><?php function {</span>', $page);
248 self::assertStringEndsWith('</code></pre></section>', $page);
249 }
250
251 public function testContextLinksToTheSourceEvenWhenParsingFailed(): void
252 {
253 $site = new ReportSite(new Catalog([], [new AnalysisProblem('src/broken.php', 'Syntax error')], ['src/broken.php' => '<?php function {']), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
254
255 self::assertSame(
256 ['On this page', [['Tables', '#tables', null, false], ['Source code', '#source', null, false]], null],
257 (new FilePage())->context($site, 'src/broken.php')[0],
258 );
259 }
260}
261