Skip to content
7 changes: 7 additions & 0 deletions .changeset/fix-territory-code-aria-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'skeleton': patch
'@shopify/cli-hydrogen': patch
'@shopify/create-hydrogen': patch
---

Fix broken `aria-label` on territory code input in address form. The label was the raw developer string `"territoryCode"` instead of a human-readable `"Country code"`.
167 changes: 167 additions & 0 deletions e2e/fixtures/delivery-address-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import {expect, Locator, Page} from '@playwright/test';

export const EMPTY_STATE_MESSAGE = 'You have no addresses saved.';

export interface AddressFormData {
firstName: string;
lastName: string;
company?: string;
address1: string;
address2?: string;
city: string;
zoneCode: string;
zip: string;
territoryCode: string;
phoneNumber?: string;
defaultAddress?: boolean;
}

export class DeliveryAddressUtil {
constructor(private page: Page) {}

async navigateToAddresses() {
await this.page.goto('/account/addresses');
await expect(
this.page.getByRole('heading', {level: 2, name: 'Addresses'}),
).toBeVisible();
}

/**
* Returns each existing address form (excluding the create form).
* The create form has id="NEW_ADDRESS_ID"; existing forms have GID-based ids.
* We select forms with Save/Delete buttons, which only appear on existing addresses.
*/
getExistingAddresses(): Locator {
return this.page
.locator('form')
.filter({has: this.page.getByRole('button', {name: 'Save'})});
}

getCreateAddressForm(): Locator {
return this.page
.locator('form')
.filter({has: this.page.getByRole('button', {name: 'Create'})});
}

getEmptyState(): Locator {
return this.page.getByText(EMPTY_STATE_MESSAGE);
}

private static readonly FIELD_LABEL_MAP: Record<
keyof Omit<AddressFormData, 'defaultAddress'>,
string
> = {
firstName: 'First name',
lastName: 'Last name',
company: 'Company',
address1: 'Address line 1',
address2: 'Address line 2',
city: 'City',
zoneCode: 'State/Province',
zip: 'Zip',
territoryCode: 'Country code',
phoneNumber: 'Phone Number',
};

async fillAddressForm(form: Locator, data: Partial<AddressFormData>) {
for (const [field, label] of Object.entries(
DeliveryAddressUtil.FIELD_LABEL_MAP,
)) {
const value = data[field as keyof typeof data];
if (value !== undefined && typeof value === 'string') {
await form.getByLabel(label).fill(value);
}
}
if (data.defaultAddress !== undefined) {
const checkbox = form.getByRole('checkbox');
const isChecked = await checkbox.isChecked();
if (data.defaultAddress !== isChecked) {
await checkbox.click();
}
}
}

async createAddress(data: AddressFormData) {
const countBefore = await this.getExistingAddresses().count();
const form = this.getCreateAddressForm();
await this.fillAddressForm(form, data);
const createButton = form.getByRole('button', {name: 'Create'});
await createButton.click();
// Wait for a new existing-address form to appear, proving the full
// create -> revalidate -> re-render cycle completed.
await this.assertAddressCount(countBefore + 1);
}

// DEVIATION: waitForResponse is used here despite the e2e guideline to
// "wait for the visible effect rather than the underlying mechanism."
// The skeleton's AddressForm has no visible success feedback (no toast, no
// flash), and the inputs are uncontrolled (defaultValue) so user-typed values
// persist in the DOM regardless of mutation success. There is no user-visible
// effect to wait for. Tests that call updateAddress must re-navigate afterward
// to verify persistence via a fresh mount from MSW state.
async updateAddress(form: Locator, data: Partial<AddressFormData>) {
await this.fillAddressForm(form, data);
const saveButton = form.getByRole('button', {name: 'Save'});
const actionResponse = this.page.waitForResponse((res) =>
res.url().includes('/account/addresses'),
);
await saveButton.click();
await actionResponse;
Comment on lines +105 to +109
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can check if the saveButton text became "Saving" and then if it became "Save" again, better than checking for response

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tried this - the "Saving" text (and the disabled state) is too ephemeral with instant MSW responses for Playwright to observe. the entire fetcher cycle (idle → submitting → loading → idle) completes within a single microtask batch, so Playwright's polling never catches the intermediate state. tested both toBeVisible() on the "Saving" text and toBeDisabled() on the button - both time out.

kept waitForResponse with an updated deviation comment explaining the microtask timing. IIRC this is the only reliable signal when the round-trip has no durable user-visible effect (uncontrolled inputs + instant mock responses).

}

async deleteAddress(form: Locator) {
const countBefore = await this.getExistingAddresses().count();
const deleteButton = form.getByRole('button', {name: 'Delete'});
await expect(deleteButton).toBeVisible();
await deleteButton.click();
// Wait for the address count to decrease, proving the full
// delete -> revalidate -> re-render cycle completed. We can't use
// not.toBeVisible() on the delete button because Playwright locators
// are lazy: after the form is removed, .first()/.last() shifts to
// the next form whose delete button IS visible.
await this.assertAddressCount(countBefore - 1);
}

async assertAddressCount(count: number) {
if (count === 0) {
await expect(this.getEmptyState()).toBeVisible();
return;
}
await expect(this.getExistingAddresses()).toHaveCount(count);
}

async assertAddressVisible(
data: Partial<Omit<AddressFormData, 'defaultAddress'>>,
) {
const entries = Object.entries(data).map(([field, value]) => ({
label:
DeliveryAddressUtil.FIELD_LABEL_MAP[
field as keyof typeof DeliveryAddressUtil.FIELD_LABEL_MAP
],
value: value as string,
}));

if (entries.length === 0) {
throw new Error('assertAddressVisible requires at least one field');
}

const [matchField, ...remainingFields] = entries;
const forms = this.getExistingAddresses();
const count = await forms.count();

for (let i = 0; i < count; i++) {
const form = forms.nth(i);
const actual = await form.getByLabel(matchField.label).inputValue();
if (actual === matchField.value) {
for (const field of remainingFields) {
await expect(form.getByLabel(field.label)).toHaveValue(field.value);
}
return;
}
}

throw new Error(
`No address found matching ${matchField.label}="${matchField.value}"`,
);
}
}
7 changes: 7 additions & 0 deletions e2e/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {CartUtil} from './cart-utils';
import {DiscountUtil} from './discount-utils';
import {GiftCardUtil} from './gift-card-utils';
import {CustomerAccountUtil} from './customer-account-utils';
import {DeliveryAddressUtil} from './delivery-address-utils';
import type {MswScenario} from './msw/scenarios';
import {getHandlersForScenario} from './msw/handlers';

Expand All @@ -27,6 +28,7 @@ export {CartUtil} from './cart-utils';
export {DiscountUtil} from './discount-utils';
export {GiftCardUtil} from './gift-card-utils';
export {CustomerAccountUtil} from './customer-account-utils';
export {DeliveryAddressUtil} from './delivery-address-utils';

export const CUSTOMER_ACCOUNT_STORAGE_STATE_PATH = path.resolve(
__dirname,
Expand Down Expand Up @@ -56,6 +58,7 @@ export const test = base.extend<
discount: DiscountUtil;
giftCard: GiftCardUtil;
customerAccount: CustomerAccountUtil;
addresses: DeliveryAddressUtil;
},
{forEachWorker: void}
>({
Expand All @@ -79,6 +82,10 @@ export const test = base.extend<
const customerAccount = new CustomerAccountUtil(page);
await use(customerAccount);
},
addresses: async ({page}, use) => {
const addresses = new DeliveryAddressUtil(page);
await use(addresses);
},
});

const TEST_STORE_KEYS = [
Expand Down
150 changes: 150 additions & 0 deletions e2e/fixtures/msw/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import type {RequestHandler} from 'msw';
import {CUSTOMER_DETAILS_QUERY} from '../../../templates/skeleton/app/graphql/customer-account/CustomerDetailsQuery';
import {CUSTOMER_ORDERS_QUERY} from '../../../templates/skeleton/app/graphql/customer-account/CustomerOrdersQuery';
import {
CREATE_ADDRESS_MUTATION,
UPDATE_ADDRESS_MUTATION,
DELETE_ADDRESS_MUTATION,
} from '../../../templates/skeleton/app/graphql/customer-account/CustomerAddressMutations';
import {mockCustomerAccountOperation} from './graphql';
import {MSW_SCENARIOS, MswScenario} from './scenarios';
import {
AddressFragment,
CustomerDetailsQuery,
CustomerOrdersQuery,
} from '../../../templates/skeleton/customer-accountapi.generated';
Expand Down Expand Up @@ -91,6 +97,150 @@ scenarios.set(MSW_SCENARIOS.customerAccountLoggedIn, {
mocksCustomerAccountApi: true,
});

/**
* Delivery addresses scenario with mutable state.
* A closure-scoped address array allows mutations to modify the list
* so subsequent CustomerDetailsQuery calls reflect CRUD changes.
*/
const DELIVERY_ADDRESS_SEED_DATA: AddressFragment[] = [
{
id: 'gid://shopify/CustomerAddress/1',
formatted: ['123 Main St', 'Anytown ON M5V 2H1', 'Canada'],
firstName: 'Taylor',
lastName: 'E2E',
company: 'Shopify',
address1: '123 Main St',
address2: '',
territoryCode: 'CA',
zoneCode: 'ON',
city: 'Anytown',
zip: 'M5V 2H1',
phoneNumber: '+16135551111',
},
{
id: 'gid://shopify/CustomerAddress/2',
formatted: ['456 Oak Ave', 'Springfield IL 62704', 'United States'],
firstName: 'Sam',
lastName: 'Test',
company: '',
address1: '456 Oak Ave',
address2: 'Apt 2B',
territoryCode: 'US',
zoneCode: 'IL',
city: 'Springfield',
zip: '62704',
phoneNumber: '+12175559999',
},
];

export const DELIVERY_ADDRESS_SEED_COUNT = DELIVERY_ADDRESS_SEED_DATA.length;

function createDeliveryAddressesScenario(): MswScenarioMeta {
let nextAddressId = DELIVERY_ADDRESS_SEED_DATA.length + 1;
const addresses: AddressFragment[] = [...DELIVERY_ADDRESS_SEED_DATA];

let defaultAddressId: string | null = addresses[0].id;

function getDefaultAddress(): AddressFragment | null {
return addresses.find((a) => a.id === defaultAddressId) ?? null;
}

return {
handlers: [
mockCustomerAccountOperation(CUSTOMER_DETAILS_QUERY, () => ({
customer: {
id: 'gid://shopify/Customer/123',
firstName: 'Taylor',
lastName: 'E2E',
defaultAddress: getDefaultAddress(),
addresses: {nodes: [...addresses]},
},
})),
mockCustomerAccountOperation(
CUSTOMER_ORDERS_QUERY,
() => customerOrdersMock,
),
mockCustomerAccountOperation(CREATE_ADDRESS_MUTATION, ({variables}) => {
const id = `gid://shopify/CustomerAddress/${nextAddressId++}`;
const newAddress: AddressFragment = {
id,
formatted: [
variables.address.address1 ?? '',
`${variables.address.city ?? ''} ${variables.address.zoneCode ?? ''} ${variables.address.zip ?? ''}`,
variables.address.territoryCode ?? '',
],
firstName: variables.address.firstName ?? '',
lastName: variables.address.lastName ?? '',
company: variables.address.company ?? '',
address1: variables.address.address1 ?? '',
address2: variables.address.address2 ?? '',
territoryCode: variables.address.territoryCode ?? '',
zoneCode: variables.address.zoneCode ?? '',
city: variables.address.city ?? '',
zip: variables.address.zip ?? '',
phoneNumber: variables.address.phoneNumber ?? '',
};
addresses.push(newAddress);
if (variables.defaultAddress) {
defaultAddressId = id;
}
return {
customerAddressCreate: {
customerAddress: {id},
userErrors: [],
},
};
}),
mockCustomerAccountOperation(UPDATE_ADDRESS_MUTATION, ({variables}) => {
const index = addresses.findIndex((a) => a.id === variables.addressId);
if (index !== -1) {
addresses[index] = {
...addresses[index],
...variables.address,
formatted: [
variables.address.address1 ?? addresses[index].address1 ?? '',
`${variables.address.city ?? addresses[index].city ?? ''} ${variables.address.zoneCode ?? addresses[index].zoneCode ?? ''} ${variables.address.zip ?? addresses[index].zip ?? ''}`,
variables.address.territoryCode ??
addresses[index].territoryCode ??
'',
],
};
}
if (variables.defaultAddress) {
defaultAddressId = variables.addressId;
}
return {
customerAddressUpdate: {
customerAddress: {id: variables.addressId},
userErrors: [],
},
};
}),
mockCustomerAccountOperation(DELETE_ADDRESS_MUTATION, ({variables}) => {
const index = addresses.findIndex((a) => a.id === variables.addressId);
if (index !== -1) {
addresses.splice(index, 1);
}
if (defaultAddressId === variables.addressId) {
defaultAddressId = addresses[0]?.id ?? null;
}
return {
customerAddressDelete: {
deletedAddressId: variables.addressId,
userErrors: [],
},
};
}),
],
mocksCustomerAccountApi: true,
};
}

scenarios.set(
MSW_SCENARIOS.deliveryAddresses,
createDeliveryAddressesScenario(),
);

function isMswScenario(scenario: string): scenario is MswScenario {
return scenarios.has(scenario);
}
Expand Down
1 change: 1 addition & 0 deletions e2e/fixtures/msw/scenarios.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export const MSW_SCENARIOS = {
customerAccountLoggedIn: 'customer-account-logged-in',
deliveryAddresses: 'delivery-addresses',
} as const;

export type MswScenario = (typeof MSW_SCENARIOS)[keyof typeof MSW_SCENARIOS];
Loading
Loading