diff --git a/tests/EndToEnd/README.md b/tests/EndToEnd/README.md index cf734220..06fb0671 100644 --- a/tests/EndToEnd/README.md +++ b/tests/EndToEnd/README.md @@ -31,8 +31,12 @@ A script file declares `namespace Firehed\PhpLsp\Tests\EndToEnd;` and names type Run `composer install` in it before the suite. Each script runs against a throwaway copy, which becomes the server's working directory. - `capabilities` is what the client declares in `initialize`. -- `Open` and `Type` are what a person does in the editor. + Declare watched-file support in any script that changes files on disk. +- `Open`, `Type`, and `Close` are what a person does in the editor. Open only files a person would have open; the server finds the rest on its own. +- `Copy` and `Delete` change the disk as another program would, and the server is not told. + `Copy` puts a prepared variant in place; variants live outside the autoload paths, such as `DiskChange/` in the fixture project. +- `ReportChanges` is the client's file watcher reporting what changed, in one notification. - `Definition` and `Complete` are requests. Each decodes the answer, failing on a malformed one, and checks it against `expect:`: one expectation, or a list that must all hold. - Definition expectations are `LandsOn(file, line, column)`, where the column is optional, and `NoAnswer()`. diff --git a/tests/EndToEnd/Session/Session.php b/tests/EndToEnd/Session/Session.php index 324714d2..3ab6ca5c 100644 --- a/tests/EndToEnd/Session/Session.php +++ b/tests/EndToEnd/Session/Session.php @@ -7,6 +7,7 @@ use Firehed\PhpLsp\Tests\EndToEnd\Client\LspClient; use Firehed\PhpLsp\Tests\EndToEnd\Client\ServerMessage; use Firehed\PhpLsp\Tests\EndToEnd\Marker\MarkerInterface; +use Firehed\PhpLsp\Tests\EndToEnd\Step\FileChange; use PHPUnit\Framework\Assert; use stdClass; @@ -54,6 +55,34 @@ public function ask(Feature $feature, string $file, MarkerInterface $at): Server return $response; } + public function close(string $file): void + { + assert(array_key_exists($file, $this->buffers), "Not open: {$file}"); + unset($this->buffers[$file], $this->versions[$file]); + + $this->client->notify('textDocument/didClose', (object) [ + 'textDocument' => ['uri' => $this->uri($file)], + ]); + } + + /** + * Writes a file on disk as another process would. Nothing is sent. + */ + public function copy(string $from, string $to): void + { + $copied = copy("{$this->projectRoot}/{$from}", "{$this->projectRoot}/{$to}"); + assert($copied, "Cannot copy {$from} to {$to}"); + } + + /** + * Deletes a file on disk as another process would. Nothing is sent. + */ + public function delete(string $file): void + { + $deleted = unlink("{$this->projectRoot}/{$file}"); + assert($deleted, "Cannot delete {$file}"); + } + /** * @return ServerMessage The response to `shutdown`. */ @@ -91,6 +120,21 @@ public function open(string $file): void ]); } + /** + * Reports changes on disk, as the client's file watcher would. + * + * @param list $changes + */ + public function reportChanges(array $changes): void + { + $this->client->notify('workspace/didChangeWatchedFiles', (object) [ + 'changes' => array_map( + fn (FileChange $change): array => ['uri' => $this->uri($change->file), 'type' => $change->type->value], + $changes, + ), + ]); + } + /** * @return ServerMessage The response to `initialize`. */ diff --git a/tests/EndToEnd/Step/Close.php b/tests/EndToEnd/Step/Close.php new file mode 100644 index 00000000..cf202f25 --- /dev/null +++ b/tests/EndToEnd/Step/Close.php @@ -0,0 +1,22 @@ +close($this->file); + } +} diff --git a/tests/EndToEnd/Step/Copy.php b/tests/EndToEnd/Step/Copy.php new file mode 100644 index 00000000..0f2067ed --- /dev/null +++ b/tests/EndToEnd/Step/Copy.php @@ -0,0 +1,29 @@ +copy($this->from, $this->to); + } +} diff --git a/tests/EndToEnd/Step/Delete.php b/tests/EndToEnd/Step/Delete.php new file mode 100644 index 00000000..5672faec --- /dev/null +++ b/tests/EndToEnd/Step/Delete.php @@ -0,0 +1,25 @@ +delete($this->file); + } +} diff --git a/tests/EndToEnd/Step/FileChange.php b/tests/EndToEnd/Step/FileChange.php new file mode 100644 index 00000000..5cfe083e --- /dev/null +++ b/tests/EndToEnd/Step/FileChange.php @@ -0,0 +1,20 @@ + $changes + */ + public function __construct(private array $changes) + { + } + + public function run(Session $session): void + { + $session->reportChanges($this->changes); + } +} diff --git a/tests/EndToEnd/scripts/disk-autoload-files-edit.php b/tests/EndToEnd/scripts/disk-autoload-files-edit.php new file mode 100644 index 00000000..9a2c8ea0 --- /dev/null +++ b/tests/EndToEnd/scripts/disk-autoload-files-edit.php @@ -0,0 +1,22 @@ +greet(); //hover:widget_greet + } + + public function useGadget(Gadget $gadget): void + { + $gadget->spin(); //hover:gadget_spin + } + + public function makeWidget(): void + { + new Widget(); //hover:new_widget + } + + public function makeLate(): void + { + new Late(); //hover:new_late + } + + public function useHelper(): void + { + helperAdded(); //hover:helper_added + } +} diff --git a/tests/Fixtures/src/DiskChange/Gadget.php b/tests/Fixtures/src/DiskChange/Gadget.php new file mode 100644 index 00000000..9ce50289 --- /dev/null +++ b/tests/Fixtures/src/DiskChange/Gadget.php @@ -0,0 +1,13 @@ +makeRequest(1, 'initialize', [ - 'processId' => getmypid(), - 'capabilities' => [], - 'rootUri' => 'file:///project', - ]), - // Initialized notification - $this->makeNotification('initialized', []), - // Open class definition file - $this->makeNotification('textDocument/didOpen', [ - 'textDocument' => [ - 'uri' => 'file:///project/src/MyClass.php', - 'languageId' => 'php', - 'version' => 1, - 'text' => 'makeNotification('textDocument/didOpen', [ - 'textDocument' => [ - 'uri' => 'file:///project/src/usage.php', - 'languageId' => 'php', - 'version' => 1, - 'text' => 'makeRequest(2, 'textDocument/definition', [ - 'textDocument' => ['uri' => 'file:///project/src/usage.php'], - 'position' => ['line' => 0, 'character' => 15], // On "MyClass" - ]), - // Shutdown - $this->makeRequest(3, 'shutdown', null), - // Exit - $this->makeNotification('exit', null), - ]; - - $input = implode('', array_map(fn($m) => $this->encode($m), $messages)); - $outputBuffer = new WritableBuffer(); - - $transport = $this->createTransport($input, $outputBuffer); - $server = Server::forProject($transport, new ServerInfo('test', '1.0'), $this->buildContainer()); - - $exitCode = $server->run(); - - self::assertSame(0, $exitCode); - - $output = $outputBuffer->buffer(); - - // Parse responses - self::assertStringContainsString('"id":2', $output, 'Should have response to definition request'); - // JSON escapes / as \/ so check for that - self::assertStringContainsString('MyClass.php', $output, 'Definition should point to MyClass.php'); - } - - /** - * @param array|null $params - * @return array - */ - private function makeRequest(int $id, string $method, ?array $params): array - { - $msg = [ - 'jsonrpc' => '2.0', - 'id' => $id, - 'method' => $method, - ]; - if ($params !== null) { - $msg['params'] = $params; - } - return $msg; - } - - /** - * @param array|null $params - * @return array - */ - private function makeNotification(string $method, ?array $params): array - { - $msg = [ - 'jsonrpc' => '2.0', - 'method' => $method, - ]; - if ($params !== null) { - $msg['params'] = $params; - } - return $msg; - } - - /** - * @param array $message - */ - private function encode(array $message): string - { - $json = json_encode($message, JSON_THROW_ON_ERROR); - return "Content-Length: " . strlen($json) . "\r\n\r\n" . $json; - } - - private function createTransport(string $input, WritableBuffer $outputBuffer): TransportInterface - { - $inputBuffer = new ReadableBuffer($input); - $reader = new MessageReader($inputBuffer); - $writer = new MessageWriter($outputBuffer); - - return new class ($reader, $writer, $outputBuffer) implements TransportInterface { - public function __construct( - private MessageReader $reader, - private MessageWriter $writer, - private WritableBuffer $outputBuffer, - ) { - } - - public function read(): Message|MalformedFrame|EndOfStream - { - return $this->reader->read(); - } - - public function write(OutgoingMessageInterface $message): void - { - $this->writer->write($message); - } - - public function close(): void - { - $this->outputBuffer->close(); - } - }; - } -} diff --git a/tests/Integration/ExternalFileChangeInvalidationTest.php b/tests/Integration/ExternalFileChangeInvalidationTest.php deleted file mode 100644 index c1169490..00000000 --- a/tests/Integration/ExternalFileChangeInvalidationTest.php +++ /dev/null @@ -1,302 +0,0 @@ -workspace = $workspace; - $this->syntax = ProductionSyntaxSource::create(); - } - - protected function tearDown(): void - { - $files = glob($this->workspace . '/*'); - if ($files !== false) { - foreach ($files as $file) { - unlink($file); - } - } - rmdir($this->workspace); - } - - public function testAnExternalEditToAnUnopenedFileIsReflectedOnTheNextQuery(): void - { - $this->writeClass('Widget', ''); - $stack = $this->stack(); - $handler = new DidChangeWatchedFilesHandler($stack->invalidator); - - $before = $stack->source->lookupClassLike($this->classNameFor('Widget')); - self::assertNotNull($before, 'the unopened workspace class must resolve from disk'); - self::assertArrayNotHasKey('greet', $before->methods, 'the original file declares no greet() method'); - - $this->writeClass('Widget', 'public function greet(): void {}'); - $handler->handle($this->changed('Widget')); - - $after = $stack->source->lookupClassLike($this->classNameFor('Widget')); - self::assertNotNull($after, 'the class still resolves after the edit'); - self::assertArrayHasKey( - 'greet', - $after->methods, - 'the external edit must be reflected on the next query rather than serving the cached class', - ); - } - - public function testABranchCheckoutIsReflectedOnTheNextQuery(): void - { - // A checkout changes several files at once; the client reports one event per file. - $this->writeClass('Alpha', ''); - $this->writeClass('Beta', ''); - $stack = $this->stack(); - $handler = new DidChangeWatchedFilesHandler($stack->invalidator); - - $this->warm($stack->source, 'Alpha', 'Beta'); - - $this->writeClass('Alpha', 'public function onAlpha(): void {}'); - $this->writeClass('Beta', 'public function onBeta(): void {}'); - $handler->handle($this->changed('Alpha', 'Beta')); - - $alpha = $stack->source->lookupClassLike($this->classNameFor('Alpha')); - $beta = $stack->source->lookupClassLike($this->classNameFor('Beta')); - self::assertNotNull($alpha); - self::assertNotNull($beta); - self::assertArrayHasKey('onAlpha', $alpha->methods, 'each checked-out file must be re-read'); - self::assertArrayHasKey('onBeta', $beta->methods, 'each checked-out file must be re-read'); - } - - public function testAFileDeletionIsReflectedOnTheNextQuery(): void - { - $this->writeClass('Widget', ''); - $stack = $this->stack(); - $handler = new DidChangeWatchedFilesHandler($stack->invalidator); - - self::assertNotNull( - $stack->source->lookupClassLike($this->classNameFor('Widget')), - 'the class resolves while its file exists', - ); - - unlink($this->workspace . '/Widget.php'); - $handler->handle($this->changed('Widget')); - - self::assertNull( - $stack->source->lookupClassLike($this->classNameFor('Widget')), - 'a deleted file must resolve to nothing on the next query, not the cached class', - ); - } - - public function testAClassCreatedAfterAFailedLookupResolvesOnTheNextQuery(): void - { - $stack = $this->stack(); - $handler = new DidChangeWatchedFilesHandler($stack->invalidator); - - self::assertNull( - $stack->source->lookupClassLike($this->classNameFor('Late')), - 'the class does not exist yet', - ); - - $this->writeClass('Late', ''); - $handler->handle($this->changed('Late')); - - self::assertNotNull( - $stack->source->lookupClassLike($this->classNameFor('Late')), - 'a file created after a miss must resolve on the next query, not stay remembered as missing', - ); - } - - public function testClosingAnEditedFileReReadsFromDiskRatherThanRestoringThePreEditCache(): void - { - $this->writeClass('Widget', ''); - $stack = $this->stack(); - $uri = $this->uriFor('Widget'); - $documents = new DocumentManager(); - $sync = new TextDocumentSyncHandler($documents, $stack->sink, $stack->invalidator); - - // The file is cached from disk, then opened and edited in the editor and - // saved to disk with a new method. - self::assertNotNull($stack->source->lookupClassLike($this->classNameFor('Widget'))); - $sync->handle($this->didOpen($uri, $this->classSource('Widget', ''))); - $this->writeClass('Widget', 'public function saved(): void {}'); - - $sync->handle($this->didClose($uri)); - - $reopened = $stack->source->lookupClassLike($this->classNameFor('Widget')); - self::assertNotNull($reopened, 'the class still resolves from disk after close'); - self::assertArrayHasKey( - 'saved', - $reopened->methods, - 'closing an edited file must re-read disk, not restore the pre-edit cached class (RFC 1 §5.3)', - ); - } - - /** - * Two derived structures stand between an `autoload.files` entry and a - * completion list — the name -> file index and the cached namespace listing — - * and either one left stale hides the edit. Enumeration is held to the same - * next-query rule as lookup (RFC 1 §5.2, §5.3). - */ - public function testAnExternalEditToAnAutoloadFilesEntryIsReflectedInEnumeration(): void - { - $path = $this->workspace . '/bootstrap.php'; - $this->writeFile($path, "knowledgeStackForMap( - new ComposerAutoloadMap(files: [$path]), - $this->syntax, - ); - $handler = new DidChangeWatchedFilesHandler($stack->invalidator); - - self::assertSame( - ['Temp\FilesBefore'], - $this->enumerate($stack->source), - 'the class the entry declares must be enumerated before the edit', - ); - - $this->writeFile($path, "handle($this->changed('bootstrap')); - - self::assertSame( - ['Temp\FilesAfter'], - $this->enumerate($stack->source), - 'the edit must reach both the derived index and the cached namespace listing', - ); - } - - /** - * @return list - */ - private function enumerate(SymbolSourceInterface $source): array - { - return array_map( - static fn(CatalogSymbol $symbol): string => $symbol->fullyQualifiedName, - $source->childrenOf(new NamespaceName('Temp'))->symbols, - ); - } - - private function writeFile(string $path, string $contents): void - { - self::assertNotFalse(file_put_contents($path, $contents), "the fixture file {$path} must be writable"); - } - - private function warm(SymbolSourceInterface $source, string ...$shortNames): void - { - foreach ($shortNames as $shortName) { - self::assertNotNull( - $source->lookupClassLike($this->classNameFor($shortName)), - "{$shortName} must resolve so its pre-change value is cached", - ); - } - } - - private function stack(): KnowledgeStack - { - return $this->knowledgeStackForMap( - new ComposerAutoloadMap(psr4: [self::NAMESPACE => [$this->workspace]]), - $this->syntax, - ); - } - - private function writeClass(string $shortName, string $body): void - { - self::assertNotFalse( - file_put_contents($this->workspace . '/' . $shortName . '.php', $this->classSource($shortName, $body)), - "the fixture class {$shortName} must be writable", - ); - } - - private function classSource(string $shortName, string $body): string - { - return " '2.0', - 'method' => 'textDocument/didOpen', - 'params' => [ - 'textDocument' => [ - 'uri' => $uri, - 'languageId' => 'php', - 'version' => 1, - 'text' => $text, - ], - ], - ]); - } - - private function didClose(string $uri): NotificationMessage - { - return NotificationMessage::fromArray([ - 'jsonrpc' => '2.0', - 'method' => 'textDocument/didClose', - 'params' => [ - 'textDocument' => ['uri' => $uri], - ], - ]); - } - - private function changed(string ...$shortNames): NotificationMessage - { - $changes = []; - foreach ($shortNames as $shortName) { - // FileChangeType.Changed = 2; the handler treats every type alike. - $changes[] = ['uri' => $this->uriFor($shortName), 'type' => 2]; - } - - return NotificationMessage::fromArray([ - 'jsonrpc' => '2.0', - 'method' => 'workspace/didChangeWatchedFiles', - 'params' => ['changes' => $changes], - ]); - } - - private function uriFor(string $shortName): string - { - return 'file://' . $this->workspace . '/' . $shortName . '.php'; - } - - private function classNameFor(string $shortName): ClasslikeName - { - return ClasslikeName::fromFullyQualified('Temp\\' . $shortName); - } -} diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 6cb74164..2efdde6b 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -31,75 +31,6 @@ class ServerTest extends TestCase use BuildsContainerTrait; use LoadsFixturesTrait; - /** - * The composed server must route an inbound `workspace/didChangeWatchedFiles` - * notification to the invalidation handler (RFC 1 §5.2, §5.3). The handler and - * its sink→backend chain are proven in isolation elsewhere; this is the only - * test that drives the notification through `Server::run()`, so it guards the - * `forProject` wiring itself. Dropping the handler from the dispatch list leaves - * the second completion serving the stale cached class — which this catches. - */ - public function testDidChangeWatchedFilesInvalidatesWorkspaceStateThroughTheComposedServer(): void - { - $root = $this->createProject('public function original(): void {}'); - $consumerUri = 'file://' . $root . '/src/consumer.php'; - $consumer = "\n"; - - try { - $input = $this->buildMessages( - $this->initializeJson(1), - $this->initializedJson(), - $this->notificationJson('textDocument/didOpen', [ - 'textDocument' => [ - 'uri' => $consumerUri, - 'languageId' => 'php', - 'version' => 1, - 'text' => $consumer, - ], - ]), - $this->completionAt(2, $consumerUri), - // The client saves a new method to the unopened workspace file and - // reports the change; the next completion must reflect it. - $this->notificationJson('workspace/didChangeWatchedFiles', [ - 'changes' => [['uri' => 'file://' . $root . '/src/Widget.php', 'type' => 2]], - ]), - $this->completionAt(3, $consumerUri), - $this->requestJson(4, 'shutdown'), - $this->notificationJson('exit'), - ); - - $outputBuffer = new WritableBuffer(); - // The whole message stream is buffered, so the disk edit is applied as - // the transport surfaces the watched-files frame — after the first - // completion is answered and before the second is read. - $transport = $this->createTransport($input, $outputBuffer, function (Message $message) use ($root): void { - if ($message->method === 'workspace/didChangeWatchedFiles') { - $this->writeWidget( - $root, - "public function original(): void {}\n public function added(): void {}", - ); - } - }); - $server = Server::forProject($transport, new ServerInfo('test', '1.0'), $this->buildContainer(), $root); - - $server->run(); - - $responses = $this->decodeResponses($outputBuffer->buffer()); - $before = $this->completionLabels($this->responseWithId($responses, 2)); - $after = $this->completionLabels($this->responseWithId($responses, 3)); - - self::assertContains('original', $before, 'the first completion resolves the workspace class from disk'); - self::assertNotContains('added', $before, 'the new method does not exist when the first completion runs'); - self::assertContains( - 'added', - $after, - 'the watched-files notification must invalidate the cached class so the edit is reflected', - ); - } finally { - $this->removeProject($root); - } - } - public function testUnknownMethodReturnsError(): void { $input = $this->buildMessages( @@ -745,84 +676,6 @@ private function countRequests(array $jsonMessages): int return $count; } - /** - * A minimal on-disk project `Server::forProject` can resolve: the generated - * Composer PSR-4 map (`Temp\` => `src/`) plus a `Widget` class with the given - * method body. Returned path is the project root. - */ - private function createProject(string $widgetBody): string - { - $root = tempnam(sys_get_temp_dir(), 'php-lsp-srv-'); - self::assertNotFalse($root, 'a temp project path must be obtainable'); - unlink($root); - - foreach (['/src', '/vendor/composer'] as $dir) { - self::assertTrue(mkdir($root . $dir, 0777, true), "the {$dir} directory must be created"); - } - - self::assertNotFalse( - file_put_contents( - $root . '/vendor/composer/autoload_psr4.php', - " [$root . '/src']], true) . ";\n", - ), - 'the generated PSR-4 map must be writable', - ); - $this->writeWidget($root, $widgetBody); - - return $root; - } - - private function writeWidget(string $root, string $body): void - { - self::assertNotFalse( - file_put_contents($root . '/src/Widget.php', "` sits at line 3, immediately after the `->`. - return $this->requestJson($id, 'textDocument/completion', [ - 'textDocument' => ['uri' => $uri], - 'position' => ['line' => 3, 'character' => 4], - ]); - } - - /** - * @param array $response - * @return list - */ - private function completionLabels(array $response): array - { - $result = $response['result'] ?? null; - self::assertIsArray($result, 'a completion response carries a result'); - $items = $result['items'] ?? null; - self::assertIsArray($items, 'a completion result carries an items list'); - - $labels = []; - foreach ($items as $item) { - self::assertIsArray($item, 'each completion item is an object'); - $label = $item['label'] ?? null; - self::assertIsString($label, 'each completion item carries a label'); - $labels[] = $label; - } - - return $labels; - } - private function initializeJson(int $id = 1): string { return $this->requestJson($id, 'initialize', ['processId' => 1234, 'capabilities' => []]); @@ -938,39 +791,23 @@ private function serverWith(TransportInterface $transport, array $handlers = []) ); } - /** - * @param (\Closure(Message): void)|null $afterRead Invoked with each read - * message, so a test can apply an out-of-band side effect (e.g. a disk - * edit) at a known point in the otherwise-buffered stream. - */ - private function createTransport( - string $input, - WritableBuffer $outputBuffer, - ?\Closure $afterRead = null, - ): TransportInterface { + private function createTransport(string $input, WritableBuffer $outputBuffer): TransportInterface + { $inputBuffer = new ReadableBuffer($input); $reader = new MessageReader($inputBuffer); $writer = new MessageWriter($outputBuffer); - return new class ($reader, $writer, $outputBuffer, $afterRead) implements TransportInterface { - /** - * @param (\Closure(Message): void)|null $afterRead - */ + return new class ($reader, $writer, $outputBuffer) implements TransportInterface { public function __construct( private MessageReader $reader, private MessageWriter $writer, private WritableBuffer $outputBuffer, - private ?\Closure $afterRead, ) { } public function read(): Message|MalformedFrame|EndOfStream { - $message = $this->reader->read(); - if ($message instanceof Message && $this->afterRead !== null) { - ($this->afterRead)($message); - } - return $message; + return $this->reader->read(); } public function write(\Firehed\PhpLsp\Protocol\OutgoingMessageInterface $message): void