Skip to content

Commit ba81627

Browse files
gin0115claude
andauthored
Add "Check only" fixer option (check links without redirecting) (#349)
* Add "Check only" fixer option (check links without redirecting) Adds a third Fixer Option, FIXER_OPTION_CHECK_ONLY, that runs the front-end link checker (recording each link's status server-side) but never rewrites the href or flags the link. Relabels the do-nothing option to "Do not auto check links". - Settings: new constant; should_render_html_link_output() now true for check-only so the checker still enqueues and records results. - Front checker JS: href swap gated to replace_link only. - Settings page: third dropdown option + relabel of do-nothing. - README: Fixer Option docs updated for all three modes. - Tests: PHPUnit coverage for the check-only enqueue/output; new Playwright fixer-mode spec + seeder mu-plugin covering all three modes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix flaky Find_Or_Create_Snapshot tests under random ordering The tests failed intermittently in CI depending on the PHPUnit random seed. Test_Find_Or_Create_Snapshot mocks archive.org at the HTTP layer (pre_http_request), but several other test classes register fake clients via the iawmlf_snapshot_client / iawmlf_link_checker_client filters and only cleared them in set_up, never in tear_down. When one ran just before Find_Or_Create, the stale client hijacked the event so get_latest_snapshot returned null and the link stuck at 'pending' instead of 'new'/'done'. - Defensive: clear both client filters in Find_Or_Create_Snapshot::set_up. - Root cause: add tear_down cleanup to the set_up-only leakers (Check_Snapshot_Status_Event, Action_Scheduler_Garbage_Collection, Report_Table_Actions). Verified: the previously-failing seed 1782941233 now passes, plus three further random seeds - 355 tests green each. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5cb3d5d commit ba81627

13 files changed

Lines changed: 374 additions & 7 deletions

.wp-env.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"plugins": [ "." ],
55
"mappings": {
66
"wp-content/mu-plugins/iawmlf-e2e-non200.php": "./e2e/mu-plugins/iawmlf-e2e-non200.php",
7-
"wp-content/mu-plugins/iawmlf-e2e-exclusion.php": "./e2e/mu-plugins/iawmlf-e2e-exclusion.php"
7+
"wp-content/mu-plugins/iawmlf-e2e-exclusion.php": "./e2e/mu-plugins/iawmlf-e2e-exclusion.php",
8+
"wp-content/mu-plugins/iawmlf-e2e-fixer-mode.php": "./e2e/mu-plugins/iawmlf-e2e-fixer-mode.php"
89
},
910
"port": 57893,
1011
"testsPort": 57894,

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,9 @@ Specify the number of consecutive failed checks before a link is marked as broke
126126
![Fixer Option](./_docs/settings--fixer-option.png)
127127

128128
You can choose what outcome you want to happen when a link is found to be broken. The options are:
129-
* **Do Nothing** - This will not change the link at all, but useful for monitoring content.
130-
* **Replace Link (No Notification)** - This will replace the broken link with the archived version, if one exists. If no archived version exists, the link will not be changed. No notice will be given to the user that the link has been replaced.
129+
* **Redirect broken links to snapshots on the Wayback Machine** - This will replace the broken link with the archived version, if one exists. If no archived version exists, the link will not be changed. No notice will be given to the user that the link has been replaced.
130+
* **Check broken links but do not redirect them** - Links are still checked and their status recorded internally (so the link report stays up to date), but the link shown to visitors is never changed. Useful for monitoring content without altering the frontend.
131+
* **Do not auto check links** - Links are not automatically checked from the frontend and are never changed.
131132

132133
#### Link Icon
133134

assets/js/src/front_link_checker.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,9 @@ const addDataAttributes = (link) => {
271271
currentLink.setAttribute('data-iawmlf-archived-last-checked', link.last_checked.date);
272272
}
273273

274-
// If the link is broken, add a class and change the href
275-
if (link.broken && linkCheckSettings.fixerOption !== 'do_nothing') {
274+
// If the link is broken, add a class and change the href.
275+
// Only 'replace_link' swaps the href; 'check_only' records the check but leaves the link untouched.
276+
if (link.broken && linkCheckSettings.fixerOption === 'replace_link') {
276277
currentLink.classList.add('iawmlf-broken-link');
277278
currentLink.href = '' !== link.archived_href ? link.archived_href : href;
278279
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<?php
2+
/**
3+
* E2E helper for the Fixer Option modes. Mapped into wp-content/mu-plugins via .wp-env.json.
4+
*
5+
* Seeds one broken link that already has an archived version and a recent check, so the
6+
* front-end checker resolves it locally (no live API call). A spec can then toggle the
7+
* fixer mode and assert the front-end behaviour for each:
8+
*
9+
* - replace_link : href rewritten to the archive URL + `iawmlf-broken-link` class added.
10+
* - check_only : link checked (data-iawmlf-archived-url set) but href left untouched, no class.
11+
* - do_nothing : the checker script is never enqueued, so the link is left entirely alone.
12+
*
13+
* Commands:
14+
* wp iawmlf-e2e-fixer-mode seed -> seeds the post + broken/archived link, prints URLs.
15+
* wp iawmlf-e2e-fixer-mode mode <value> -> sets the fixer option (replace_link|check_only|do_nothing).
16+
* wp iawmlf-e2e-fixer-mode cleanup -> removes the post + link and resets the fixer option.
17+
*
18+
* @package Internet_Archive\Wayback_Machine_Link_Fixer\E2E
19+
*/
20+
21+
defined( 'ABSPATH' ) || exit;
22+
23+
use Internet_Archive\Wayback_Machine_Link_Fixer\Settings\Settings;
24+
25+
const IAWMLF_E2E_FIXER_LINK_URL = 'https://example.com/iawmlf-e2e-fixer-mode';
26+
const IAWMLF_E2E_FIXER_ARCHIVED_URL = 'https://web.archive.org/web/2024/https://example.com/iawmlf-e2e-fixer-mode';
27+
const IAWMLF_E2E_FIXER_POST_SLUG = 'iawmlf-e2e-fixer-mode';
28+
29+
if ( ! ( defined( 'WP_CLI' ) && WP_CLI ) ) {
30+
return;
31+
}
32+
33+
/**
34+
* Delete the seeded post + link and reset the fixer option (idempotent).
35+
*
36+
* @return void
37+
*/
38+
function iawmlf_e2e_fixer_mode_cleanup(): void {
39+
global $wpdb;
40+
41+
update_option( Settings::FIXER_OPTION, Settings::FIXER_OPTION_REPLACE_LINK );
42+
$wpdb->delete( Settings::get_link_table_name(), array( 'url' => IAWMLF_E2E_FIXER_LINK_URL ), array( '%s' ) );
43+
44+
foreach ( get_posts(
45+
array(
46+
'name' => IAWMLF_E2E_FIXER_POST_SLUG,
47+
'post_type' => 'post',
48+
'post_status' => 'any',
49+
'numberposts' => 1,
50+
)
51+
) as $p ) {
52+
wp_delete_post( $p->ID, true );
53+
}
54+
}
55+
56+
// Seed one broken link with an archived version + a recent check, and a post that links to it.
57+
WP_CLI::add_command(
58+
'iawmlf-e2e-fixer-mode seed',
59+
function (): void {
60+
global $wpdb;
61+
62+
update_option( Settings::POST_ACTIVATION_ONBOARDING_KEY, Settings::ONBOARDING_COMPLETED_OPTION );
63+
update_option( Settings::SETUP_WIZARD_COMPLETED_KEY, true );
64+
update_option( Settings::SETUP_WIZARD_STEP_KEY, 'complete' );
65+
update_option( Settings::LINK_EXCLUSIONS, array() );
66+
67+
iawmlf_e2e_fixer_mode_cleanup();
68+
69+
// Broken, with an archived version and a recent check so the front-end resolves it
70+
// locally (checks within the delay window skip the live REST call).
71+
$wpdb->insert(
72+
Settings::get_link_table_name(),
73+
array(
74+
'url' => IAWMLF_E2E_FIXER_LINK_URL,
75+
'archived' => IAWMLF_E2E_FIXER_ARCHIVED_URL,
76+
'checks' => wp_json_encode( array( array( 'date' => gmdate( 'Y-m-d H:i:s' ), 'http_code' => 404 ) ) ),
77+
'message' => '',
78+
'redirect_url' => '',
79+
'is_broken' => 1,
80+
'excluded' => 0,
81+
'archive_process' => 'done',
82+
),
83+
array( '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s' )
84+
);
85+
$link_id = (int) $wpdb->insert_id;
86+
87+
$post_id = wp_insert_post(
88+
array(
89+
'post_title' => 'IAWMLF E2E Fixer Mode',
90+
'post_name' => IAWMLF_E2E_FIXER_POST_SLUG,
91+
'post_status' => 'publish',
92+
'post_type' => 'post',
93+
'post_content' => sprintf( '<p>Check: <a href="%s">fixer mode link</a></p>', esc_url( IAWMLF_E2E_FIXER_LINK_URL ) ),
94+
)
95+
);
96+
97+
update_post_meta( $post_id, Settings::LINK_META_KEY, array( $link_id ) );
98+
99+
echo 'POST_URL=' . get_permalink( $post_id ) . "\n";
100+
echo 'LINK_URL=' . IAWMLF_E2E_FIXER_LINK_URL . "\n";
101+
echo 'ARCHIVED_URL=' . IAWMLF_E2E_FIXER_ARCHIVED_URL . "\n";
102+
}
103+
);
104+
105+
// Set the fixer option: wp iawmlf-e2e-fixer-mode mode <replace_link|check_only|do_nothing>.
106+
WP_CLI::add_command(
107+
'iawmlf-e2e-fixer-mode mode',
108+
function ( array $args ): void {
109+
$mode = isset( $args[0] ) ? (string) $args[0] : '';
110+
$valid = array(
111+
Settings::FIXER_OPTION_REPLACE_LINK,
112+
Settings::FIXER_OPTION_CHECK_ONLY,
113+
Settings::FIXER_OPTION_DO_NOTHING,
114+
);
115+
116+
if ( ! in_array( $mode, $valid, true ) ) {
117+
WP_CLI::error( 'Invalid mode: ' . $mode );
118+
}
119+
120+
update_option( Settings::FIXER_OPTION, $mode );
121+
echo 'MODE=' . $mode . "\n";
122+
}
123+
);
124+
125+
WP_CLI::add_command(
126+
'iawmlf-e2e-fixer-mode cleanup',
127+
function (): void {
128+
iawmlf_e2e_fixer_mode_cleanup();
129+
echo "CLEANED=1\n";
130+
}
131+
);

e2e/specs/fixer-mode.spec.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
const { test, expect } = require( '@playwright/test' );
2+
const { execSync } = require( 'child_process' );
3+
const path = require( 'path' );
4+
5+
/**
6+
* The Fixer Option controls what the front-end checker does with a broken link:
7+
*
8+
* - replace_link : rewrite the href to the archived version + add `iawmlf-broken-link`.
9+
* - check_only : still check the link (records the result), but never touch the href.
10+
* - do_nothing : don't even enqueue the checker; the link is left completely alone.
11+
*
12+
* One broken link (with an archived version) is seeded once, then the mode is toggled
13+
* and the same page re-loaded to assert each contract.
14+
*/
15+
16+
const PLUGIN_DIR = 'wp-content/plugins/internet-archive-wayback-machine-link-fixer';
17+
18+
function wpCli( cmd ) {
19+
return execSync(
20+
`npx wp-env run cli --env-cwd='${ PLUGIN_DIR }' -- wp ${ cmd }`,
21+
{ cwd: path.join( __dirname, '../..' ), encoding: 'utf8' }
22+
);
23+
}
24+
25+
function parse( output, key ) {
26+
const m = output.match( new RegExp( `^${ key }=(.+)$`, 'm' ) );
27+
if ( ! m ) {
28+
throw new Error( `Seeder did not print ${ key }. Output was:\n${ output }` );
29+
}
30+
return m[ 1 ].trim();
31+
}
32+
33+
function seed() {
34+
const output = wpCli( 'iawmlf-e2e-fixer-mode seed' );
35+
return {
36+
postUrl: parse( output, 'POST_URL' ),
37+
linkUrl: parse( output, 'LINK_URL' ),
38+
archivedUrl: parse( output, 'ARCHIVED_URL' ),
39+
};
40+
}
41+
42+
function setMode( mode ) {
43+
wpCli( `iawmlf-e2e-fixer-mode mode ${ mode }` );
44+
}
45+
46+
test.describe( 'fixer option modes', () => {
47+
let seeded;
48+
49+
test.beforeAll( () => {
50+
seeded = seed();
51+
} );
52+
53+
test.afterAll( () => {
54+
wpCli( 'iawmlf-e2e-fixer-mode cleanup' );
55+
} );
56+
57+
test( 'replace_link rewrites the broken link to the archived version', async ( { page } ) => {
58+
setMode( 'replace_link' );
59+
await page.goto( seeded.postUrl );
60+
61+
const link = page.locator( 'a', { hasText: 'fixer mode link' } );
62+
await expect( link ).toBeVisible();
63+
64+
// The checker ran and, in replace mode, swapped the href + flagged it broken.
65+
// The plugin renders archived links on the web-wp.archive.org host, so match the
66+
// archive path host-agnostically rather than a specific subdomain.
67+
await expect( link ).toHaveAttribute( 'data-iawmlf-archived-url', /.+/ );
68+
await expect( link ).toHaveClass( /iawmlf-broken-link/ );
69+
await expect( link ).toHaveAttribute( 'href', /archive\.org\/web/ );
70+
} );
71+
72+
test( 'check_only checks the link but leaves the href untouched', async ( { page } ) => {
73+
setMode( 'check_only' );
74+
await page.goto( seeded.postUrl );
75+
76+
const link = page.locator( 'a', { hasText: 'fixer mode link' } );
77+
await expect( link ).toBeVisible();
78+
79+
// The checker still ran (proves the check is recorded)...
80+
await expect( link ).toHaveAttribute( 'data-iawmlf-archived-url', /.+/ );
81+
82+
// ...but the visitor-facing link is unchanged: no class, original href.
83+
await expect( link ).not.toHaveClass( /iawmlf-broken-link/ );
84+
await expect( link ).toHaveAttribute( 'href', seeded.linkUrl );
85+
} );
86+
87+
test( 'do_nothing does not enqueue the checker and leaves the link alone', async ( { page } ) => {
88+
setMode( 'do_nothing' );
89+
await page.goto( seeded.postUrl );
90+
91+
const link = page.locator( 'a', { hasText: 'fixer mode link' } );
92+
await expect( link ).toBeVisible();
93+
94+
// The front-end checker script is never enqueued in this mode.
95+
await expect(
96+
page.locator( 'script#iawm-link-fixer-front-link-checker-js' )
97+
).toHaveCount( 0 );
98+
99+
// So the link is completely untouched.
100+
await expect( link ).not.toHaveClass( /iawmlf-broken-link/ );
101+
await expect( link ).not.toHaveAttribute( 'data-iawmlf-archived-url', /.+/ );
102+
await expect( link ).toHaveAttribute( 'href', seeded.linkUrl );
103+
} );
104+
} );

src/Dashboard/Settings_Page.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1430,8 +1430,11 @@ public function render_fixer_option(): void {
14301430
<option value="<?php echo esc_attr( Settings::FIXER_OPTION_REPLACE_LINK ); ?>" <?php selected( Settings::get_fixer_option(), Settings::FIXER_OPTION_REPLACE_LINK ); ?>>
14311431
<?php esc_html_e( 'Redirect broken links to snapshots on the Wayback Machine', 'internet-archive-wayback-machine-link-fixer' ); ?>
14321432
</option>
1433+
<option value="<?php echo esc_attr( Settings::FIXER_OPTION_CHECK_ONLY ); ?>" <?php selected( Settings::get_fixer_option(), Settings::FIXER_OPTION_CHECK_ONLY ); ?>>
1434+
<?php esc_html_e( 'Check broken links but do not redirect them', 'internet-archive-wayback-machine-link-fixer' ); ?>
1435+
</option>
14331436
<option value="<?php echo esc_attr( Settings::FIXER_OPTION_DO_NOTHING ); ?>" <?php selected( Settings::get_fixer_option(), Settings::FIXER_OPTION_DO_NOTHING ); ?>>
1434-
<?php esc_html_e( 'Do not redirect broken links', 'internet-archive-wayback-machine-link-fixer' ); ?>
1437+
<?php esc_html_e( 'Do not auto check links', 'internet-archive-wayback-machine-link-fixer' ); ?>
14351438
</option>
14361439
</select>
14371440
<p class="description">

src/Settings/Settings.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ class Settings {
5757
// Fixer Options
5858
public const FIXER_OPTION_DO_NOTHING = 'do_nothing';
5959
public const FIXER_OPTION_REPLACE_LINK = 'replace_link';
60+
public const FIXER_OPTION_CHECK_ONLY = 'check_only';
6061

6162
// Onboarding options.
6263
public const ONBOARDING_COMPLETED_OPTION = self::SETTINGS_PREFIX . 'onboarding_completed';
@@ -488,12 +489,16 @@ public static function get_reporting_page_capability(): string {
488489
/**
489490
* Checks if the HTML link output should be rendered in the frontend.
490491
*
492+
* True for both "replace link" and "check only" - the frontend checker is
493+
* needed in both cases to verify links and record the result. The check-only
494+
* mode simply skips the href swap (handled JS-side via the fixer option).
495+
*
491496
* @since 1.3.1
492497
*
493498
* @return boolean
494499
*/
495500
public static function should_render_html_link_output(): bool {
496-
$allowed = in_array( self::get_fixer_option(), array( self::FIXER_OPTION_REPLACE_LINK ), true );
501+
$allowed = in_array( self::get_fixer_option(), array( self::FIXER_OPTION_REPLACE_LINK, self::FIXER_OPTION_CHECK_ONLY ), true );
497502

498503
/**
499504
* Filter to allow or disallow the HTML link output in the frontend.

tests/Event/Test_Check_Snapshot_Status_Event.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,17 @@ public function set_up(): void {
4848
parent::set_up();
4949
}
5050

51+
/**
52+
* Tear down
53+
*/
54+
public function tear_down(): void {
55+
// Remove client mocks so they can't leak into later tests under random ordering.
56+
remove_all_filters( 'iawmlf_snapshot_client' );
57+
remove_all_filters( 'iawmlf_link_checker_client' );
58+
59+
parent::tear_down();
60+
}
61+
5162
/**
5263
* Set the snapshot client to return a mocked response.
5364
*

tests/Event/Test_Find_Or_Create_Snapshot.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ public function set_up(): void {
3232
// Clear the actionscheduler_actions table.
3333
$this->wpdb->query( "TRUNCATE TABLE {$this->wpdb->prefix}actionscheduler_actions" );
3434

35+
// Clear any snapshot / link-checker client mocks leaked from other tests (random order):
36+
// this class mocks at the HTTP layer, so a stale injected client would leave links 'pending'.
37+
remove_all_filters( 'iawmlf_snapshot_client' );
38+
remove_all_filters( 'iawmlf_link_checker_client' );
39+
3540
parent::set_up();
3641
}
3742

tests/Report/Test_Report_Table_Actions.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@ public function set_up(): void {
4949
parent::set_up();
5050
}
5151

52+
/**
53+
* Tear down
54+
*/
55+
public function tear_down(): void {
56+
// Remove client mocks so they can't leak into later tests under random ordering.
57+
remove_all_filters( 'iawmlf_snapshot_client' );
58+
remove_all_filters( 'iawmlf_link_checker_client' );
59+
60+
parent::tear_down();
61+
}
62+
5263
/**
5364
* Set the snapshot client to return a mocked response.
5465
*

0 commit comments

Comments
 (0)