chore: bump playwright (#13947)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Updated Playwright test tooling to 1.58.2 across the repository and
test packages.

* **Tests**
* Improved end-to-end robustness: replaced fragile timing/coordinate
logic with element-based interactions, added polling/retry checks for
flaky asserts and async state, and simplified input/rename flows to
reduce test flakiness.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-27 22:56:43 +08:00
committed by GitHub
parent c90f173821
commit a4e2242b8d
16 changed files with 170 additions and 119 deletions

View File

@@ -56,7 +56,7 @@
"@faker-js/faker": "^10.1.0", "@faker-js/faker": "^10.1.0",
"@istanbuljs/schema": "^0.1.3", "@istanbuljs/schema": "^0.1.3",
"@magic-works/i18n-codegen": "^0.6.1", "@magic-works/i18n-codegen": "^0.6.1",
"@playwright/test": "=1.52.0", "@playwright/test": "=1.58.2",
"@smarttools/eslint-plugin-rxjs": "^1.0.8", "@smarttools/eslint-plugin-rxjs": "^1.0.8",
"@taplo/cli": "^0.7.0", "@taplo/cli": "^0.7.0",
"@toeverything/infra": "workspace:*", "@toeverything/infra": "workspace:*",

View File

@@ -7,7 +7,7 @@
}, },
"devDependencies": { "devDependencies": {
"@affine-test/kit": "workspace:*", "@affine-test/kit": "workspace:*",
"@playwright/test": "=1.52.0" "@playwright/test": "=1.58.2"
}, },
"version": "0.26.3" "version": "0.26.3"
} }

View File

@@ -7,7 +7,7 @@
}, },
"devDependencies": { "devDependencies": {
"@affine-test/kit": "workspace:*", "@affine-test/kit": "workspace:*",
"@playwright/test": "=1.52.0" "@playwright/test": "=1.58.2"
}, },
"version": "0.26.3" "version": "0.26.3"
} }

View File

@@ -7,7 +7,7 @@
}, },
"devDependencies": { "devDependencies": {
"@affine-test/kit": "workspace:*", "@affine-test/kit": "workspace:*",
"@playwright/test": "=1.52.0" "@playwright/test": "=1.58.2"
}, },
"version": "0.26.3" "version": "0.26.3"
} }

View File

@@ -8,10 +8,10 @@
"devDependencies": { "devDependencies": {
"@affine-test/kit": "workspace:*", "@affine-test/kit": "workspace:*",
"@affine/electron-api": "workspace:*", "@affine/electron-api": "workspace:*",
"@playwright/test": "=1.52.0", "@playwright/test": "=1.58.2",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"playwright": "=1.52.0" "playwright": "=1.58.2"
}, },
"version": "0.26.3" "version": "0.26.3"
} }

View File

@@ -417,12 +417,19 @@ test('Create a new page with special characters in the title and search for this
await clickNewPageButton(page); await clickNewPageButton(page);
await getBlockSuiteEditorTitle(page).click(); await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill(specialTitle); await getBlockSuiteEditorTitle(page).fill(specialTitle);
await page.keyboard.press('Enter');
await expect(getBlockSuiteEditorTitle(page)).toContainText(specialTitle);
await openQuickSearchByShortcut(page); await openQuickSearchByShortcut(page);
await insertInputText(page, specialTitle); await insertInputText(page, specialTitle);
await page.waitForTimeout(1000); await expect
.poll(async () => {
await assertResultList(page, [specialTitle, specialTitle]); const labels = await page
.locator('[cmdk-item] [data-testid=cmdk-label]')
.allInnerTexts();
return labels.some(label => label.split('\n').includes(specialTitle));
})
.toBe(true);
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
await assertTitle(page, specialTitle); await assertTitle(page, specialTitle);

View File

@@ -9,7 +9,7 @@
"@affine-test/kit": "workspace:*", "@affine-test/kit": "workspace:*",
"@affine-tools/cli": "workspace:*", "@affine-tools/cli": "workspace:*",
"@affine-tools/utils": "workspace:*", "@affine-tools/utils": "workspace:*",
"@playwright/test": "=1.52.0", "@playwright/test": "=1.58.2",
"webpack": "^5.102.1" "webpack": "^5.102.1"
}, },
"version": "0.26.3" "version": "0.26.3"

View File

@@ -7,7 +7,7 @@
}, },
"devDependencies": { "devDependencies": {
"@affine-test/kit": "workspace:*", "@affine-test/kit": "workspace:*",
"@playwright/test": "=1.52.0" "@playwright/test": "=1.58.2"
}, },
"version": "0.26.3" "version": "0.26.3"
} }

View File

@@ -15,7 +15,6 @@ import {
pressShiftTab, pressShiftTab,
pressTab, pressTab,
redoByKeyboard, redoByKeyboard,
SHORT_KEY,
type, type,
undoByKeyboard, undoByKeyboard,
} from './utils/actions/keyboard.js'; } from './utils/actions/keyboard.js';
@@ -113,11 +112,13 @@ function getAttachment(page: Page) {
await attachment.click(); await attachment.click();
await expect(toolbar).toBeVisible(); await expect(toolbar).toBeVisible();
await renameBtn.click(); await renameBtn.click();
await page.keyboard.press(`${SHORT_KEY}+a`, { delay: 50 }); await expect(renameInput).toBeVisible();
await pressBackspace(page); await renameInput.fill(newName);
await type(page, newName);
await pressEnter(page); await pressEnter(page);
expect(await getName()).toContain(newName); await expect(renameInput).not.toBeVisible();
if (newName.length > 0) {
await expect.poll(getName).toContain(newName);
}
}, },
// external // external
@@ -215,11 +216,11 @@ test('should rename attachment works', async ({ page }) => {
await expect(renameInput).not.toBeVisible(); await expect(renameInput).not.toBeVisible();
await rename('new-name'); await rename('new-name');
expect(await getName()).toBe('new-name.png'); await expect.poll(getName).toBe('new-name.png');
await rename(''); await rename('');
expect(await getName()).toBe('.png'); await expect.poll(getName).toBe('.png');
await rename('abc'); await rename('abc');
expect(await getName()).toBe('abc'); await expect.poll(getName).toBe('abc');
}); });
test('should turn attachment to image works', async ({ page }, testInfo) => { test('should turn attachment to image works', async ({ page }, testInfo) => {

View File

@@ -143,17 +143,20 @@ async function assertSelection(
rangeIndex: number, rangeIndex: number,
rangeLength = 0 rangeLength = 0
) { ) {
const actual = await page.evaluate( await expect
([richTextIndex]) => { .poll(async () => {
const richText = return page.evaluate(
document?.querySelectorAll('test-rich-text')[richTextIndex]; ([richTextIndex]) => {
// @ts-expect-error getInlineRange const richText =
const inlineEditor = richText.inlineEditor; document?.querySelectorAll('test-rich-text')[richTextIndex];
return inlineEditor?.getInlineRange(); // @ts-expect-error getInlineRange
}, const inlineEditor = richText.inlineEditor;
[richTextIndex] return inlineEditor?.getInlineRange();
); },
expect(actual).toEqual({ index: rangeIndex, length: rangeLength }); [richTextIndex]
);
})
.toEqual({ index: rangeIndex, length: rangeLength });
} }
test('basic input', async ({ page, browserName }) => { test('basic input', async ({ page, browserName }) => {
@@ -1113,16 +1116,14 @@ test('embed', async ({ page }) => {
await assertSelection(page, 0, 3, 1); await assertSelection(page, 0, 3, 1);
// try to update cursor position and select embed element by clicking embed element // try to update cursor position and select embed element by clicking embed element
let rect = await getInlineRangeIndexRect(page, [0, 1]); const embeds = page.locator('[data-v-embed="true"]');
await page.mouse.click(rect.x + 3, rect.y); await embeds.nth(0).click();
await assertSelection(page, 0, 1, 1); await assertSelection(page, 0, 1, 1);
rect = await getInlineRangeIndexRect(page, [0, 2]); await embeds.nth(1).click();
await page.mouse.click(rect.x + 3, rect.y);
await assertSelection(page, 0, 2, 1); await assertSelection(page, 0, 2, 1);
rect = await getInlineRangeIndexRect(page, [0, 3]); await embeds.nth(2).click();
await page.mouse.click(rect.x + 3, rect.y);
await assertSelection(page, 0, 3, 1); await assertSelection(page, 0, 3, 1);
}); });

View File

@@ -820,10 +820,10 @@ export async function updateExistedBrushElementSize(
page: Page, page: Page,
nthSizeButton: 1 | 2 | 3 | 4 | 5 | 6 nthSizeButton: 1 | 2 | 3 | 4 | 5 | 6
) { ) {
// get the nth brush size button // pick from the visible panel to avoid strict-mode collisions from hidden/duplicate toolbars
const btn = page.locator( const btn = page
`edgeless-line-width-panel .point-button:nth-child(${nthSizeButton})` .locator('edgeless-line-width-panel:visible .point-button')
); .nth(nthSizeButton - 1);
await btn.click(); await btn.click();
} }

View File

@@ -1015,31 +1015,63 @@ export async function getIndexCoordinate(
[richTextIndex, vIndex]: [number, number], [richTextIndex, vIndex]: [number, number],
coordOffSet: { x: number; y: number } = { x: 0, y: 0 } coordOffSet: { x: number; y: number } = { x: 0, y: 0 }
) { ) {
const coord = await page.evaluate( for (let attempt = 0; attempt < 20; attempt++) {
({ richTextIndex, vIndex, coordOffSet, currentEditorIndex }) => { const coord = await page.evaluate(
const editorHost = ({ richTextIndex, vIndex, coordOffSet, currentEditorIndex }) => {
document.querySelectorAll('editor-host')[currentEditorIndex]; const editorHost =
const richText = editorHost.querySelectorAll('rich-text')[ document.querySelectorAll('editor-host')[currentEditorIndex];
richTextIndex const richTexts = Array.from(
] as any; editorHost?.querySelectorAll('rich-text') ?? []
const domRange = richText.inlineEditor.toDomRange({ );
index: vIndex, if (!richTexts.length) {
length: 0, return null;
}); }
const pointBound = domRange.getBoundingClientRect(); const richText = richTexts[
return { Math.min(richTextIndex, richTexts.length - 1)
x: pointBound.left + coordOffSet.x, ] as any;
y: pointBound.top + pointBound.height / 2 + coordOffSet.y, const inlineEditor = richText?.inlineEditor;
}; if (!inlineEditor) {
}, return null;
{ }
richTextIndex, const clampedIndex = Math.max(
vIndex, 0,
coordOffSet, Math.min(vIndex, inlineEditor.yTextLength ?? vIndex)
currentEditorIndex, );
const domRange = inlineEditor.toDomRange({
index: clampedIndex,
length: 0,
});
if (!domRange) {
return null;
}
const pointBound = domRange.getBoundingClientRect();
if (
!Number.isFinite(pointBound.left) ||
!Number.isFinite(pointBound.top)
) {
return null;
}
return {
x: pointBound.left + coordOffSet.x,
y: pointBound.top + pointBound.height / 2 + coordOffSet.y,
};
},
{
richTextIndex,
vIndex,
coordOffSet,
currentEditorIndex,
}
);
if (coord) {
return coord;
} }
await page.waitForTimeout(50);
}
throw new Error(
`Failed to get index coordinate: richTextIndex=${richTextIndex}, vIndex=${vIndex}`
); );
return coord;
} }
export function inlineEditorInnerTextToString(innerText: string): string { export function inlineEditorInnerTextToString(innerText: string): string {

View File

@@ -159,17 +159,20 @@ export async function assertTextContain(page: Page, text: string, i = 0) {
} }
export async function assertRichTexts(page: Page, texts: string[]) { export async function assertRichTexts(page: Page, texts: string[]) {
const actualTexts = await page.evaluate(() => { await expect
const editorHost = document.querySelector('editor-host'); .poll(async () => {
const richTexts = Array.from( return page.evaluate(() => {
editorHost?.querySelectorAll<RichText>('rich-text') ?? [] const editorHost = document.querySelector('editor-host');
); const richTexts = Array.from(
return richTexts.map(richText => { editorHost?.querySelectorAll<RichText>('rich-text') ?? []
const editor = richText.inlineEditor as AffineInlineEditor; );
return editor.yText.toString(); return richTexts.map(richText => {
}); const editor = richText.inlineEditor as AffineInlineEditor;
}); return editor.yText.toString();
expect(actualTexts).toEqual(texts); });
});
})
.toEqual(texts);
} }
export async function assertEdgelessCanvasText(page: Page, text: string) { export async function assertEdgelessCanvasText(page: Page, text: string) {
@@ -274,16 +277,20 @@ export async function assertRichTextInlineRange(
rangeIndex: number, rangeIndex: number,
rangeLength = 0 rangeLength = 0
) { ) {
const actual = await page.evaluate( await expect
([richTextIndex]) => { .poll(async () => {
const editorHost = document.querySelector('editor-host'); return page.evaluate(
const richText = editorHost?.querySelectorAll('rich-text')[richTextIndex]; ([richTextIndex]) => {
const inlineEditor = richText?.inlineEditor; const editorHost = document.querySelector('editor-host');
return inlineEditor?.getInlineRange(); const richText =
}, editorHost?.querySelectorAll('rich-text')[richTextIndex];
[richTextIndex] const inlineEditor = richText?.inlineEditor;
); return inlineEditor?.getInlineRange();
expect(actual).toEqual({ index: rangeIndex, length: rangeLength }); },
[richTextIndex]
);
})
.toEqual({ index: rangeIndex, length: rangeLength });
} }
export async function assertNativeSelectionRangeCount( export async function assertNativeSelectionRangeCount(
@@ -1137,15 +1144,18 @@ export async function assertNoteSequence(page: Page, expected: string) {
} }
export async function assertBlockSelections(page: Page, paths: string[]) { export async function assertBlockSelections(page: Page, paths: string[]) {
const selections = await page.evaluate(() => { await expect
const host = document.querySelector<EditorHost>('editor-host'); .poll(async () => {
if (!host) { const selections = await page.evaluate(() => {
throw new Error('editor-host host not found'); const host = document.querySelector<EditorHost>('editor-host');
} if (!host) {
return host.selection.value.filter(b => b.type === 'block'); throw new Error('editor-host host not found');
}); }
const actualPaths = selections.map(selection => selection.blockId); return host.selection.value.filter(b => b.type === 'block');
expect(actualPaths).toEqual(paths); });
return selections.map(selection => selection.blockId);
})
.toEqual(paths);
} }
export async function assertTextSelection( export async function assertTextSelection(

View File

@@ -9,7 +9,7 @@
"@affine-test/kit": "workspace:*", "@affine-test/kit": "workspace:*",
"@blocksuite/affine": "workspace:*", "@blocksuite/affine": "workspace:*",
"@blocksuite/integration-test": "workspace:*", "@blocksuite/integration-test": "workspace:*",
"@playwright/test": "=1.52.0", "@playwright/test": "=1.58.2",
"@toeverything/theme": "^1.1.23", "@toeverything/theme": "^1.1.23",
"json-stable-stringify": "^1.2.1", "json-stable-stringify": "^1.2.1",
"rxjs": "^7.8.2" "rxjs": "^7.8.2"

View File

@@ -14,7 +14,7 @@
"@affine-tools/utils": "workspace:*", "@affine-tools/utils": "workspace:*",
"@blocksuite/affine": "workspace:*", "@blocksuite/affine": "workspace:*",
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"@playwright/test": "=1.52.0", "@playwright/test": "=1.58.2",
"@toeverything/infra": "workspace:*", "@toeverything/infra": "workspace:*",
"express": "^5.0.0", "express": "^5.0.0",
"http-proxy-middleware": "^3.0.3" "http-proxy-middleware": "^3.0.3"

View File

@@ -24,7 +24,7 @@ __metadata:
resolution: "@affine-test/affine-cloud-copilot@workspace:tests/affine-cloud-copilot" resolution: "@affine-test/affine-cloud-copilot@workspace:tests/affine-cloud-copilot"
dependencies: dependencies:
"@affine-test/kit": "workspace:*" "@affine-test/kit": "workspace:*"
"@playwright/test": "npm:=1.52.0" "@playwright/test": "npm:=1.58.2"
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@@ -33,7 +33,7 @@ __metadata:
resolution: "@affine-test/affine-cloud@workspace:tests/affine-cloud" resolution: "@affine-test/affine-cloud@workspace:tests/affine-cloud"
dependencies: dependencies:
"@affine-test/kit": "workspace:*" "@affine-test/kit": "workspace:*"
"@playwright/test": "npm:=1.52.0" "@playwright/test": "npm:=1.58.2"
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@@ -42,7 +42,7 @@ __metadata:
resolution: "@affine-test/affine-desktop-cloud@workspace:tests/affine-desktop-cloud" resolution: "@affine-test/affine-desktop-cloud@workspace:tests/affine-desktop-cloud"
dependencies: dependencies:
"@affine-test/kit": "workspace:*" "@affine-test/kit": "workspace:*"
"@playwright/test": "npm:=1.52.0" "@playwright/test": "npm:=1.58.2"
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@@ -52,10 +52,10 @@ __metadata:
dependencies: dependencies:
"@affine-test/kit": "workspace:*" "@affine-test/kit": "workspace:*"
"@affine/electron-api": "workspace:*" "@affine/electron-api": "workspace:*"
"@playwright/test": "npm:=1.52.0" "@playwright/test": "npm:=1.58.2"
"@types/fs-extra": "npm:^11.0.4" "@types/fs-extra": "npm:^11.0.4"
fs-extra: "npm:^11.2.0" fs-extra: "npm:^11.2.0"
playwright: "npm:=1.52.0" playwright: "npm:=1.58.2"
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@@ -66,7 +66,7 @@ __metadata:
"@affine-test/kit": "workspace:*" "@affine-test/kit": "workspace:*"
"@affine-tools/cli": "workspace:*" "@affine-tools/cli": "workspace:*"
"@affine-tools/utils": "workspace:*" "@affine-tools/utils": "workspace:*"
"@playwright/test": "npm:=1.52.0" "@playwright/test": "npm:=1.58.2"
webpack: "npm:^5.102.1" webpack: "npm:^5.102.1"
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@@ -76,7 +76,7 @@ __metadata:
resolution: "@affine-test/affine-mobile@workspace:tests/affine-mobile" resolution: "@affine-test/affine-mobile@workspace:tests/affine-mobile"
dependencies: dependencies:
"@affine-test/kit": "workspace:*" "@affine-test/kit": "workspace:*"
"@playwright/test": "npm:=1.52.0" "@playwright/test": "npm:=1.58.2"
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@@ -87,7 +87,7 @@ __metadata:
"@affine-test/kit": "workspace:*" "@affine-test/kit": "workspace:*"
"@blocksuite/affine": "workspace:*" "@blocksuite/affine": "workspace:*"
"@blocksuite/integration-test": "workspace:*" "@blocksuite/integration-test": "workspace:*"
"@playwright/test": "npm:=1.52.0" "@playwright/test": "npm:=1.58.2"
"@toeverything/theme": "npm:^1.1.23" "@toeverything/theme": "npm:^1.1.23"
json-stable-stringify: "npm:^1.2.1" json-stable-stringify: "npm:^1.2.1"
rxjs: "npm:^7.8.2" rxjs: "npm:^7.8.2"
@@ -101,7 +101,7 @@ __metadata:
"@affine-tools/utils": "workspace:*" "@affine-tools/utils": "workspace:*"
"@blocksuite/affine": "workspace:*" "@blocksuite/affine": "workspace:*"
"@node-rs/argon2": "npm:^2.0.2" "@node-rs/argon2": "npm:^2.0.2"
"@playwright/test": "npm:=1.52.0" "@playwright/test": "npm:=1.58.2"
"@toeverything/infra": "workspace:*" "@toeverything/infra": "workspace:*"
express: "npm:^5.0.0" express: "npm:^5.0.0"
http-proxy-middleware: "npm:^3.0.3" http-proxy-middleware: "npm:^3.0.3"
@@ -809,7 +809,7 @@ __metadata:
"@faker-js/faker": "npm:^10.1.0" "@faker-js/faker": "npm:^10.1.0"
"@istanbuljs/schema": "npm:^0.1.3" "@istanbuljs/schema": "npm:^0.1.3"
"@magic-works/i18n-codegen": "npm:^0.6.1" "@magic-works/i18n-codegen": "npm:^0.6.1"
"@playwright/test": "npm:=1.52.0" "@playwright/test": "npm:=1.58.2"
"@smarttools/eslint-plugin-rxjs": "npm:^1.0.8" "@smarttools/eslint-plugin-rxjs": "npm:^1.0.8"
"@taplo/cli": "npm:^0.7.0" "@taplo/cli": "npm:^0.7.0"
"@toeverything/infra": "workspace:*" "@toeverything/infra": "workspace:*"
@@ -10900,14 +10900,14 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@playwright/test@npm:=1.52.0": "@playwright/test@npm:=1.58.2":
version: 1.52.0 version: 1.58.2
resolution: "@playwright/test@npm:1.52.0" resolution: "@playwright/test@npm:1.58.2"
dependencies: dependencies:
playwright: "npm:1.52.0" playwright: "npm:1.58.2"
bin: bin:
playwright: cli.js playwright: cli.js
checksum: 10/e18a4eb626c7bc6cba212ff2e197cf9ae2e4da1c91bfdf08a744d62e27222751173e4b220fa27da72286a89a3b4dea7c09daf384d23708f284b64f98e9a63a88 checksum: 10/58bf90139280a0235eeeb6049e9fb4db6425e98be1bf0cc17913b068eef616cf67be57bfb36dc4cb56bcf116f498ffd0225c4916e85db404b343ea6c5efdae13
languageName: node languageName: node
linkType: hard linkType: hard
@@ -31101,27 +31101,27 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"playwright-core@npm:1.52.0": "playwright-core@npm:1.58.2":
version: 1.52.0 version: 1.58.2
resolution: "playwright-core@npm:1.52.0" resolution: "playwright-core@npm:1.58.2"
bin: bin:
playwright-core: cli.js playwright-core: cli.js
checksum: 10/42e13f5f98dc25ebc95525fb338a215b9097b2ba39d41e99972a190bf75d79979f163f5bc07b1ca06847ee07acb2c9b487d070fab67e9cd55e33310fc05aca3c checksum: 10/8a98fcf122167e8703d525db2252de0e3da4ab9110ab6ea9951247e52d846310eb25ea2c805e1b7ccb54b4010c44e5adc3a76aae6da02f34324ccc3e76683bb1
languageName: node languageName: node
linkType: hard linkType: hard
"playwright@npm:1.52.0, playwright@npm:=1.52.0": "playwright@npm:1.58.2, playwright@npm:=1.58.2":
version: 1.52.0 version: 1.58.2
resolution: "playwright@npm:1.52.0" resolution: "playwright@npm:1.58.2"
dependencies: dependencies:
fsevents: "npm:2.3.2" fsevents: "npm:2.3.2"
playwright-core: "npm:1.52.0" playwright-core: "npm:1.58.2"
dependenciesMeta: dependenciesMeta:
fsevents: fsevents:
optional: true optional: true
bin: bin:
playwright: cli.js playwright: cli.js
checksum: 10/214175446089000c2ac997b925063b95f7d86d129c5d7c74caa5ddcb05bcad598dfd569d2133a10dc82d288bf67e7858877dcd099274b0b928b9c63db7d6ecec checksum: 10/d89d6c8a32388911b9aff9ee0f1a90076219f15c804f2b287db048b9e9cde182aea3131fac1959051d25189ed4218ec4272b137c83cd7f9cd24781cbc77edd86
languageName: node languageName: node
linkType: hard linkType: hard