diff --git a/tests/phpunit/tests/admin/wpPluginsListTableActionLinks.php b/tests/phpunit/tests/admin/wpPluginsListTableActionLinks.php
new file mode 100644
index 0000000000000..2cc3211a51091
--- /dev/null
+++ b/tests/phpunit/tests/admin/wpPluginsListTableActionLinks.php
@@ -0,0 +1,467 @@
+user->create( array( 'role' => 'administrator' ) );
+ self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) );
+
+ if ( is_multisite() ) {
+ grant_super_admin( self::$admin_id );
+ }
+ }
+
+ /**
+ * Revokes the super admin grant before the fixture users are deleted.
+ */
+ public static function wpTearDownAfterClass() {
+ if ( is_multisite() ) {
+ revoke_super_admin( self::$admin_id );
+ }
+ }
+
+ /**
+ * Sets up the Plugins screen, the list table globals and the dependency state.
+ */
+ public function set_up() {
+ parent::set_up();
+
+ foreach ( array( 'status', 'page', 's', 'totals', '_paused_plugins', 'current_screen' ) as $name ) {
+ $this->original_globals[ $name ] = $GLOBALS[ $name ] ?? null;
+ }
+
+ set_current_screen( 'plugins' );
+
+ $GLOBALS['status'] = 'all';
+ $GLOBALS['page'] = 1;
+ $GLOBALS['s'] = '';
+ $GLOBALS['totals'] = array();
+ $GLOBALS['_paused_plugins'] = array();
+
+ $this->set_property_value( 'plugins', array() );
+ $this->set_property_value( 'dependencies', array() );
+ $this->set_property_value( 'dependency_slugs', array() );
+ $this->set_property_value( 'dependency_filepaths', array() );
+ $this->set_property_value( 'circular_dependencies_slugs', array() );
+ $this->set_property_value( 'circular_dependencies_pairs', array() );
+ $this->set_property_value( 'initialized', true );
+
+ update_option( 'active_plugins', array() );
+ update_site_option( 'active_sitewide_plugins', array() );
+
+ $this->actions = null;
+ $this->slug = 'action-links-plugin-' . ++self::$counter;
+
+ add_filter( 'plugin_action_links', array( $this, 'capture_actions' ), PHP_INT_MAX );
+
+ wp_set_current_user( self::$admin_id );
+ }
+
+ /**
+ * Restores the globals and removes the test plugin.
+ */
+ public function tear_down() {
+ foreach ( $this->original_globals as $name => $value ) {
+ $GLOBALS[ $name ] = $value;
+ }
+
+ $this->remove_plugin();
+
+ parent::tear_down();
+ }
+
+ /**
+ * Tests the Deactivate link of an active plugin.
+ *
+ * @ticket 65819
+ *
+ * @group ms-excluded
+ */
+ public function test_should_link_deactivate_for_active_plugin() {
+ $this->activate();
+
+ $this->assertSame(
+ array(
+ 'deactivate' => 'Deactivate',
+ ),
+ $this->get_actions()
+ );
+ }
+
+ /**
+ * Tests that an active plugin that other active plugins require cannot be deactivated.
+ *
+ * @ticket 65819
+ *
+ * @group ms-excluded
+ */
+ public function test_should_not_link_deactivate_for_plugin_with_active_dependents() {
+ $this->set_dependent( true );
+ $this->activate();
+
+ $this->assertSame(
+ array(
+ 'deactivate' => 'DeactivateYou cannot deactivate this plugin as other plugins depend on it.',
+ ),
+ $this->get_actions()
+ );
+ }
+
+ /**
+ * Tests the Resume link of an active plugin that was paused.
+ *
+ * @ticket 65819
+ *
+ * @group ms-excluded
+ */
+ public function test_should_link_resume_for_paused_plugin() {
+ $this->activate();
+ $GLOBALS['_paused_plugins'] = array(
+ $this->slug => array(
+ 'type' => E_ERROR,
+ 'line' => 1,
+ 'file' => WP_PLUGIN_DIR . '/' . $this->get_file(),
+ 'message' => 'Error.',
+ ),
+ );
+
+ $this->assertSame(
+ 'Resume',
+ $this->get_actions()['resume']
+ );
+ }
+
+ /**
+ * Tests the Activate and Delete links of an inactive plugin.
+ *
+ * @ticket 65819
+ *
+ * @group ms-excluded
+ */
+ public function test_should_link_activate_and_delete_for_inactive_plugin() {
+ $this->assertSame(
+ array(
+ 'activate' => 'Activate',
+ 'delete' => 'Delete',
+ ),
+ $this->get_actions()
+ );
+ }
+
+ /**
+ * Tests that a plugin whose requirements are not met cannot be activated.
+ *
+ * @ticket 65819
+ *
+ * @group ms-excluded
+ *
+ * @dataProvider data_unmet_requirements
+ *
+ * @param array $plugin_data The plugin's requirement headers.
+ */
+ public function test_should_not_activate_plugin_with_unmet_requirements( $plugin_data ) {
+ $this->assertSame( 'Cannot Activate', $this->get_actions( $plugin_data )['activate'] );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array[]
+ */
+ public static function data_unmet_requirements() {
+ return array(
+ 'PHP version' => array( array( 'RequiresPHP' => '99.0' ) ),
+ 'WordPress version' => array( array( 'RequiresWP' => '99.0' ) ),
+ );
+ }
+
+ /**
+ * Tests that a plugin with unmet dependencies cannot be activated.
+ *
+ * @ticket 65819
+ *
+ * @group ms-excluded
+ */
+ public function test_should_not_activate_plugin_with_unmet_dependencies() {
+ $this->set_property_value( 'dependencies', array( $this->get_file() => array( 'missing' ) ) );
+ $this->set_property_value( 'dependency_filepaths', array( 'missing' => false ) );
+
+ $this->assertSame(
+ 'ActivateYou cannot activate this plugin as it has unmet requirements.',
+ $this->get_actions()['activate']
+ );
+ }
+
+ /**
+ * Tests that a plugin that other plugins require cannot be deleted, unless the dependency is circular.
+ *
+ * @ticket 65819
+ *
+ * @group ms-excluded
+ *
+ * @dataProvider data_circular_dependencies
+ *
+ * @param bool $circular Whether the dependency is circular.
+ */
+ public function test_delete_link_for_plugin_with_dependents( $circular ) {
+ $this->set_dependent( false );
+
+ if ( $circular ) {
+ $this->set_property_value( 'circular_dependencies_slugs', array( $this->slug ) );
+ }
+
+ $this->assertSame(
+ $circular
+ ? 'Delete'
+ : 'DeleteYou cannot delete this plugin as other plugins require it.',
+ $this->get_actions()['delete']
+ );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array[]
+ */
+ public static function data_circular_dependencies() {
+ return array(
+ 'regular dependency' => array( false ),
+ 'circular dependency' => array( true ),
+ );
+ }
+
+ /**
+ * Tests that a user who cannot manage plugins gets no action links.
+ *
+ * @ticket 65819
+ *
+ * @dataProvider data_active_states
+ *
+ * @param bool $active Whether the plugin is active.
+ */
+ public function test_should_not_add_links_for_user_without_capabilities( $active ) {
+ wp_set_current_user( self::$subscriber_id );
+
+ if ( $active ) {
+ $this->activate();
+ }
+
+ $this->assertSame( array(), $this->get_actions() );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array[]
+ */
+ public static function data_active_states() {
+ return array(
+ 'active plugin' => array( true ),
+ 'inactive plugin' => array( false ),
+ );
+ }
+
+ /**
+ * Tests that a network-active plugin only shows that it is network active on a site.
+ *
+ * @ticket 65819
+ *
+ * @group ms-required
+ */
+ public function test_should_only_show_network_active_on_site_screen() {
+ update_site_option( 'active_sitewide_plugins', array( $this->get_file() => time() ) );
+
+ $this->assertSame( array( 'network_active' => 'Network Active' ), $this->get_actions() );
+ }
+
+ /**
+ * Tests that the filter receives the plugin file, data and context.
+ *
+ * @ticket 65819
+ */
+ public function test_should_pass_plugin_and_context_to_filter() {
+ $filter = new MockAction();
+ add_filter( 'plugin_action_links', array( $filter, 'filter' ), 10, 4 );
+
+ $this->get_actions();
+
+ $args = $filter->get_args();
+
+ $this->assertSame( $this->get_file(), $args[0][1], 'The filter should receive the plugin file.' );
+ $this->assertSame( 'My Plugin', $args[0][2]['Name'], 'The filter should receive the plugin data.' );
+ $this->assertSame( 'all', $args[0][3], 'The filter should receive the context.' );
+ }
+
+ /**
+ * Captures the actions passed to the plugin_action_links filter.
+ *
+ * @param array $actions The action links.
+ * @return array The unchanged action links.
+ */
+ public function capture_actions( $actions ) {
+ $this->actions = $actions;
+
+ return $actions;
+ }
+
+ /**
+ * Renders the row of the test plugin and returns its action links.
+ *
+ * @param array $plugin_data The plugin data to add.
+ * @return array The action links.
+ */
+ private function get_actions( $plugin_data = array() ) {
+ // is_network_only_plugin() reads the plugin headers on multisite.
+ $dir = WP_PLUGIN_DIR . '/' . $this->slug;
+ $this->remove_plugin();
+ $this->assertTrue( mkdir( $dir ), 'The test plugin directory should be created.' );
+ $this->assertNotFalse( file_put_contents( $dir . '/' . $this->slug . '.php', " 'plugins' ) );
+
+ get_echo(
+ array( $table, 'single_row' ),
+ array(
+ array(
+ $this->get_file(),
+ array_merge(
+ array(
+ 'Name' => 'My Plugin',
+ 'slug' => $this->slug,
+ 'Description' => '',
+ 'Version' => '1.0',
+ 'Author' => '',
+ 'AuthorURI' => '',
+ 'PluginURI' => '',
+ ),
+ $plugin_data
+ ),
+ ),
+ )
+ );
+
+ return $this->actions;
+ }
+
+ /**
+ * Returns the plugin file of the test plugin.
+ *
+ * @return string The plugin file.
+ */
+ private function get_file() {
+ return "{$this->slug}/{$this->slug}.php";
+ }
+
+ /**
+ * Returns the expected URL of an action.
+ *
+ * @param string $action The action.
+ * @param string $nonce The nonce action.
+ * @return string The escaped URL.
+ */
+ private function get_url( $action, $nonce ) {
+ return "plugins.php?action={$action}&plugin={$this->slug}%2F{$this->slug}.php&plugin_status=all&paged=1&s&_wpnonce=" . wp_create_nonce( $nonce );
+ }
+
+ /**
+ * Returns the expected Delete URL.
+ *
+ * @return string The escaped URL.
+ */
+ private function get_delete_url() {
+ return "plugins.php?action=delete-selected&checked%5B0%5D={$this->slug}%2F{$this->slug}.php&plugin_status=all&paged=1&s&_wpnonce=" . wp_create_nonce( 'bulk-plugins' );
+ }
+
+ /**
+ * Activates the test plugin.
+ */
+ private function activate() {
+ update_option( 'active_plugins', array_merge( get_option( 'active_plugins' ), array( $this->get_file() ) ) );
+ }
+
+ /**
+ * Makes another plugin require the test plugin.
+ *
+ * @param bool $active Whether the other plugin is active.
+ */
+ private function set_dependent( $active ) {
+ $this->set_property_value( 'plugins', array( 'dependent/dependent.php' => array( 'Name' => 'Dependent' ) ) );
+ $this->set_property_value( 'dependencies', array( 'dependent/dependent.php' => array( $this->slug ) ) );
+ $this->set_property_value( 'dependency_slugs', array( $this->slug ) );
+
+ if ( $active ) {
+ update_option( 'active_plugins', array( 'dependent/dependent.php' ) );
+ }
+ }
+
+ /**
+ * Removes the test plugin, including one left over by an interrupted run.
+ */
+ private function remove_plugin() {
+ $dir = WP_PLUGIN_DIR . '/' . $this->slug;
+
+ if ( is_dir( $dir ) ) {
+ $this->rmdir( $dir );
+ rmdir( $dir );
+ }
+ }
+}