Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .wp-env.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"phpVersion": "8.3",
"plugins": [ "." ],
"mappings": {
"wp-content/mu-plugins/iawmlf-e2e-non200.php": "./e2e/mu-plugins/iawmlf-e2e-non200.php"
"wp-content/mu-plugins/iawmlf-e2e-non200.php": "./e2e/mu-plugins/iawmlf-e2e-non200.php",
"wp-content/mu-plugins/iawmlf-e2e-exclusion.php": "./e2e/mu-plugins/iawmlf-e2e-exclusion.php"
},
"port": 57893,
"testsPort": 57894,
Expand Down
112 changes: 112 additions & 0 deletions e2e/mu-plugins/iawmlf-e2e-exclusion.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php
/**
* E2E helper for the link-details exclusion "unwind" test. Mapped into wp-content/mu-plugins
* via .wp-env.json.
*
* Lets a spec drive all three exclusion layers on one seeded link and peel them back one at a
* time to verify the panel's priority order (per-link flag -> settings list -> built-in list):
*
* - built-in layer : an option-gated `iawmlf_bundled_link_exclusions` filter (the real list is
* a code constant, so this is the only runtime way to toggle it).
* - settings layer : the spec flips the normal `iawmlf_link_exclusions` option directly.
* - per-link flag : `wp iawmlf-e2e-exclusion flag <link_id> <0|1>`.
*
* Commands:
* wp iawmlf-e2e-exclusion seed -> seeds one broken, un-excluded link, prints LINK_ID.
* wp iawmlf-e2e-exclusion flag … -> sets that link's per-link excluded flag.
* wp iawmlf-e2e-exclusion cleanup -> removes the link + all e2e exclusion state.
*
* @package Internet_Archive\Wayback_Machine_Link_Fixer\E2E
*/

defined( 'ABSPATH' ) || exit;

use Internet_Archive\Wayback_Machine_Link_Fixer\Settings\Settings;
use Internet_Archive\Wayback_Machine_Link_Fixer\Link\Link_Repository;

const IAWMLF_E2E_EXCL_URL = 'https://example.com/iawmlf-e2e-panel-link';
const IAWMLF_E2E_BUILTIN_OPT = 'iawmlf_e2e_builtin_exclusion';

// Built-in list layer, toggled by an option so the spec can flip it via wp-cli.
add_filter(
'iawmlf_bundled_link_exclusions',
function ( array $list ): array {
if ( get_option( IAWMLF_E2E_BUILTIN_OPT ) ) {
$list[] = '*iawmlf-e2e-panel-link*';
}
return $list;
}
);

if ( ! ( defined( 'WP_CLI' ) && WP_CLI ) ) {
return;
}

/**
* Remove the seeded link and all e2e exclusion state (idempotent).
*
* @return void
*/
function iawmlf_e2e_exclusion_cleanup(): void {
global $wpdb;
delete_option( IAWMLF_E2E_BUILTIN_OPT );
update_option( Settings::LINK_EXCLUSIONS, array() );
$wpdb->delete( Settings::get_link_table_name(), array( 'url' => IAWMLF_E2E_EXCL_URL ), array( '%s' ) );
}

// Seed one broken, un-excluded link and mark onboarding complete so admin pages render.
WP_CLI::add_command(
'iawmlf-e2e-exclusion seed',
function (): void {
global $wpdb;

update_option( Settings::POST_ACTIVATION_ONBOARDING_KEY, Settings::ONBOARDING_COMPLETED_OPTION );
update_option( Settings::SETUP_WIZARD_COMPLETED_KEY, true );
update_option( Settings::SETUP_WIZARD_STEP_KEY, 'complete' );

iawmlf_e2e_exclusion_cleanup();

$wpdb->insert(
Settings::get_link_table_name(),
array(
'url' => IAWMLF_E2E_EXCL_URL,
'archived' => 'https://web.archive.org/web/2024/' . IAWMLF_E2E_EXCL_URL,
'checks' => wp_json_encode( array( array( 'date' => gmdate( 'Y-m-d H:i:s' ), 'http_code' => 404 ) ) ),
'message' => '',
'redirect_url' => '',
'is_broken' => 1,
'excluded' => 0,
'archive_process' => 'done',
),
array( '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s' )
);

echo 'LINK_ID=' . (int) $wpdb->insert_id . "\n";
}
);

// Toggle the per-link excluded flag: wp iawmlf-e2e-exclusion flag <link_id> <0|1>.
WP_CLI::add_command(
'iawmlf-e2e-exclusion flag',
function ( array $args ): void {
$id = isset( $args[0] ) ? (int) $args[0] : 0;
$on = isset( $args[1] ) && '0' !== $args[1];

$repo = new Link_Repository();
$link = $repo->find_by_id( $id );
if ( $link ) {
$link->set_excluded( $on );
$repo->upsert( $link );
}

echo 'FLAG=' . ( $on ? '1' : '0' ) . "\n";
}
);

WP_CLI::add_command(
'iawmlf-e2e-exclusion cleanup',
function (): void {
iawmlf_e2e_exclusion_cleanup();
echo "CLEANED=1\n";
}
);
94 changes: 94 additions & 0 deletions e2e/specs/link-details-exclusion.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
const { test, expect } = require( '@playwright/test' );
const { execSync } = require( 'child_process' );
const path = require( 'path' );

/**
* Link-details exclusion priority — the "unwind" test.
*
* One link is excluded by all three layers at once, then peeled back one at a time. The "Link
* Exclusion" panel must walk the priority order as each layer is removed:
*
* all three on -> per-link flag wins the message ("currently excluded"), checkbox read-only
* remove flag -> settings list ("excluded by your exclusion settings list"), read-only
* remove settings-> built-in list ("excluded by the built-in exclusion list"), read-only
* remove built-in-> nothing ("Excluding this link will stop it…"), editable checkbox
*
* Layers are toggled via the iawmlf-e2e-exclusion mu-plugin (per-link flag + option-gated
* built-in filter) and the normal settings option.
*/

const PLUGIN_DIR = 'wp-content/plugins/internet-archive-wayback-machine-link-fixer';
const REPO_ROOT = path.join( __dirname, '../..' );

function wpCli( command ) {
return execSync(
`npx wp-env run cli --env-cwd='${ PLUGIN_DIR }' -- ${ command }`,
{ cwd: REPO_ROOT, encoding: 'utf8' }
);
}

function seedLink() {
const output = wpCli( 'wp iawmlf-e2e-exclusion seed' );
const match = output.match( /^LINK_ID=(\d+)$/m );
if ( ! match ) {
throw new Error( `Seeder did not print LINK_ID. Output was:\n${ output }` );
}
return match[ 1 ];
}

const setPerLinkFlag = ( linkId, on ) => wpCli( `wp iawmlf-e2e-exclusion flag ${ linkId } ${ on ? 1 : 0 }` );
const setSettingsList = ( jsonArray ) => wpCli( `wp option update iawmlf_link_exclusions '${ jsonArray }' --format=json` );
const setBuiltInList = ( on ) => ( on
? wpCli( 'wp option update iawmlf_e2e_builtin_exclusion 1' )
: wpCli( 'wp option delete iawmlf_e2e_builtin_exclusion' ) );

const PATTERN = '["*iawmlf-e2e-panel-link*"]';

test.describe( 'link details exclusion priority', () => {
let linkId;
let detailsPath;

test.beforeAll( () => {
linkId = seedLink();
detailsPath = `/wp-admin/admin.php?page=iawmlf-links&iawmlf_link_id=${ linkId }`;
} );

test.afterAll( () => {
setPerLinkFlag( linkId, false );
setSettingsList( '[]' );
setBuiltInList( false );
wpCli( 'wp iawmlf-e2e-exclusion cleanup' );
} );

test( 'all three on, then peeled back one at a time, walk the priority order', async ( { page } ) => {
const panel = page.locator( '#iawmlf_link_exclusion .inside' );
const toggle = page.locator( '#iawmlf_toggle_exclusion' );

// 1) All three layers on: the per-link flag wins the message; checkbox is read-only.
setPerLinkFlag( linkId, true );
setSettingsList( PATTERN );
setBuiltInList( true );
await page.goto( detailsPath );
await expect( panel ).toContainText( 'currently excluded' );
await expect( toggle ).toBeDisabled();

// 2) Remove the per-link flag: the settings list now owns it.
setPerLinkFlag( linkId, false );
await page.goto( detailsPath );
await expect( panel ).toContainText( 'excluded by your exclusion settings list' );
await expect( toggle ).toBeDisabled();

// 3) Remove the settings list: the built-in list now owns it.
setSettingsList( '[]' );
await page.goto( detailsPath );
await expect( panel ).toContainText( 'excluded by the built-in exclusion list' );
await expect( toggle ).toBeDisabled();

// 4) Remove the built-in list: nothing excludes it — the checkbox is editable again.
setBuiltInList( false );
await page.goto( detailsPath );
await expect( panel ).toContainText( 'Excluding this link will stop it' );
await expect( toggle ).toBeEnabled();
await expect( toggle ).toHaveAttribute( 'name', 'iawmlf_exclude_link' );
} );
} );
10 changes: 10 additions & 0 deletions src/Action/Link_Check_Action.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

use Throwable;
use Internet_Archive\Wayback_Machine_Link_Fixer\Link\Link;
use Internet_Archive\Wayback_Machine_Link_Fixer\Link\Link_Exclusion;
use Internet_Archive\Wayback_Machine_Link_Fixer\Link\Link_Repository;

defined( 'ABSPATH' ) || exit;
Expand Down Expand Up @@ -90,6 +91,15 @@ public function check_link( int $link_id ): array {
);
}

// If the link matches our exclusion list (built-in or settings), skip checking it.
if ( Link_Exclusion::get_instance()->is_excluded( $link ) ) {
return array(
'link' => $link,
'checked' => false,
'valid' => true,
);
}

// Get the current status.
try {
$status = $this->link_checker->check_single( $link->get_href() );
Expand Down
73 changes: 48 additions & 25 deletions src/Link/Link_Exclusion.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,32 @@
defined( 'ABSPATH' ) || exit;

/**
* Handles the exclusion of links.
* Handles the exclusion of links against the built-in and user exclusion lists.
*/
class Link_Exclusion {

/**
* Holds the global exclusions.
* The built-in exclusion patterns.
*
* @var string[]
*/
private static $exclusions = null;
private $bundled;

/**
* The user settings exclusion patterns.
*
* @var string[]
*/
private $settings;

/**
* Create an instance of the class.
* Create an instance of the class, loading both exclusion lists.
*
* @return void
*/
public function __construct() {
// If we have not loaded the exclusions, load them.
if ( null === self::$exclusions ) {
self::$exclusions = get_option( Settings::LINK_EXCLUSIONS, array() );
}
$this->bundled = Settings::get_bundled_link_exclusions();
$this->settings = Settings::get_link_exclusions();
}

/**
Expand All @@ -49,50 +53,69 @@ public static function get_instance(): Link_Exclusion {
}

/**
* Checks if a give link is excluded.
* Checks if a given link is excluded by either the built-in or the settings list.
*
* @param Link $link The link to check.
* @param integer|null $post_id The post ID to check.
*
* @return boolean
*/
public function is_excluded( Link $link, ?int $post_id = null ): bool {
$excluded = $this->is_globally_excluded( $link ) || $this->is_settings_excluded( $link );

return null !== $post_id
? apply_filters( 'iawmlf_exclude_link_from_post', $this->is_global_excluded( $link ), $link, $post_id )
: $this->is_global_excluded( $link );
? apply_filters( 'iawmlf_exclude_link_from_post', $excluded, $link, $post_id )
: $excluded;
}

/**
* Filters an array of links to check for exclusions.
* Checks if a link is in the built-in exclusion list.
*
* @param Link[] $links The links to check.
* @param integer|null $post_id The post ID to check.
* @param Link $link The link to check.
*
* @return Link[]
* @return boolean
*/
public function filter_excluded( array $links, ?int $post_id = null ): array {
return array_filter(
$links,
function ( Link $link ) use ( $post_id ): bool {
return ! $this->is_global_excluded( $link, $post_id );
public function is_globally_excluded( Link $link ): bool {
foreach ( $this->bundled as $pattern ) {
if ( fnmatch( $pattern, $link->get_href() ) ) {
return true;
}
);
}

return false;
}

/**
* Checks if a link is excluded from the global exclusions.
* Checks if a link is in the user settings exclusion list.
*
* @param Link $link The link to check.
*
* @return boolean
*/
private function is_global_excluded( Link $link ): bool {
foreach ( self::$exclusions as $ex ) {
if ( fnmatch( $ex, $link->get_href() ) ) {
public function is_settings_excluded( Link $link ): bool {
foreach ( $this->settings as $pattern ) {
if ( fnmatch( $pattern, $link->get_href() ) ) {
return true;
}
}

return false;
}

/**
* Filters an array of links to remove excluded ones.
*
* @param Link[] $links The links to check.
* @param integer|null $post_id The post ID to check.
*
* @return Link[]
*/
public function filter_excluded( array $links, ?int $post_id = null ): array {
return array_filter(
$links,
function ( Link $link ) use ( $post_id ): bool {
return ! $this->is_excluded( $link, $post_id );
}
);
}
}
Loading
Loading