diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 69ef7cea29..3d3c22755f 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -36,7 +36,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: ⚙️ Use Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ env.NODE_VERSION }} cache: npm @@ -78,7 +78,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: ⚙️ Use Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ env.NODE_VERSION }} cache: npm diff --git a/docs/angular/quickstart.mdx b/docs/angular/quickstart.mdx index b97c7fb3db..fdf66675f9 100644 --- a/docs/angular/quickstart.mdx +++ b/docs/angular/quickstart.mdx @@ -110,7 +110,7 @@ And its template in `app.component.html`: Routes are defined in `app.routes.ts`: ```ts title="src/app/app.routes.ts" -import { Routes } from '@angular/router'; +import type { Routes } from '@angular/router'; export const routes: Routes = [ { @@ -376,7 +376,7 @@ export class NewPage implements OnInit { ngOnInit() {} - scrollToBottom = () => { + scrollToBottom = (): void => { this.content.scrollToBottom(300); }; } @@ -435,9 +435,7 @@ ionic cap open android -

- Capacitorを使用してネイティブデバイス機能にアクセスし、アプリをiOS、Android、Webにデプロイする方法を探索します。 -

+ Capacitorを使用してネイティブデバイス機能にアクセスし、アプリをiOS、Android、Webにデプロイする方法を探索します。
diff --git a/docs/angular/testing.mdx b/docs/angular/testing.mdx index 2958e4256e..1ef845389a 100644 --- a/docs/angular/testing.mdx +++ b/docs/angular/testing.mdx @@ -1,88 +1,131 @@ --- -title: テスト +title: Testing --- Angular Unit and End-to-End Testing for Ionic App Components -Ionic CLI を使用して `@ionic/angular` アプリケーションを生成すると、アプリケーションのユニットテストとエンドツーエンドのテスト用に自動的に準備されます。これは Angular CLI で使われる設定と同じものです。Angular で作られたアプリケーションのテストについての詳細は Angular Testing Guide をご参照ください。 +When an `@ionic/angular` application is generated using the Ionic CLI, it is automatically set up for unit testing and end-to-end testing of the application: -## テストの原則 {/* #testing-principles */} +- Unit tests run with [Vitest](https://vitest.dev) through the Angular CLI's `@angular/build:unit-test` builder, in a [jsdom](https://github.com/jsdom/jsdom) environment. Refer to the [Angular Testing Guide](https://angular.dev/guide/testing) for detailed information on testing Angular applications. +- End-to-end tests run with [Playwright](https://playwright.dev) against the app, which Playwright starts with `npm start`. -アプリケーションをテストするときは、テストによってシステムに欠陥があるかどうかを確認できる、ということを覚えておくことが一番です。しかし、どんなささいなシステムも完全に欠陥のないことを証明することは不可能です。このため、テストの目的はコードが正しいことを確認することではなく、コードの中の問題を見つけることです。これは微妙ですが、重要な違いです。 +The generated `package.json` includes scripts for running them: -もし私たちがコードが正しいことを証明しようとするのであれば、私たちはコードを通じて幸せな道を歩み続けようとするでしょう。もし私が問題の発見しようとするのであれば、コードをより完全に実行し、そこに潜むバグを発見する可能性が高くなります。 +| Script | Description | +| ------------------------- | ------------------------------------------------------------ | +| `npm test` | Runs the unit tests once, then the end-to-end tests. | +| `npm run test:unit` | Runs the unit tests once. | +| `npm run test:unit:watch` | Runs the unit tests and re-runs them when files change. | +| `npm run test:e2e` | Runs the end-to-end tests, starting the dev server for them. | -最初からアプリケーションのテストを開始することも最良です。これにより、修正が容易な段階で早期に欠陥を発見できます。またこれにより、システムに新しい機能が追加されたときに、コードを確実にリファクタリングすることもできます。 +:::note +Playwright downloads its browsers separately from the npm package. Run `npx playwright install` once before running the end-to-end tests for the first time. +::: -## ユニットテスト {/* #unit-testing */} +## Testing Principles {/* #testing-principles */} -ユニットテストでは、システムの他の部分から分離して、単一のコードユニット(Component、Page、Service、Pipe など)を実行します。分離は、コードの依存関係の代わりにモックオブジェクトを注入することによって実現されます。モックオブジェクトによって、テストは依存関係の切り出しをきめ細かく制御することができます。モックによって、どの依存関係が呼び出され、何が渡されたかをテストで判断することもできます。 +When testing an application, it is best to keep in mind that testing can show if defects are present in a system. However, it is impossible to prove that any non-trivial system is completely free of defects. For this reason, the goal of testing is not to verify that the code is correct but to find problems within the code. This is a subtle but important distinction. -適切に記述されたユニットテストは、コードの単位とそれに含まれる機能が `describe()` コールバックによって記述されるように構成されています。コード単位とその機能の要件は、`it()` コールバックによってテストされます。`describe()` コールバックと `it()` コールバックの説明を読むと、フレーズとして意味がわかります。ネストされた `describe()` と最後の `it()` の記述をつなげると、テストケースを完全に記述する文が形成されます。 +If we set out to prove that the code is correct, we are more likely to stick to the happy path through the code. If we set out to find problems, we are more likely to more fully exercise the code and find the bugs that are lurking there. -ユニットテストはコードを分離して実行するため、高速で堅牢であり、高度なコードカバレッジが可能です。 +It is also best to begin testing an application from the very start. This allows defects to be found early in the process when they are easier to fix. This also allows code to be refactored with confidence as new features are added to the system. -### モックの利用 {/* #using-mocks */} +## Unit Testing {/* #unit-testing */} -ユニットテストでは、コードをコードをモジュールで分離して実行します。これを簡単にするには、Jasmine(https://jasmine.github.io/) を使用することをお勧めします。Jasmine は、テスト実行中に依存関係の代わりにモックオブジェクト(Jasmine は 「スパイ」 と呼んでいます)を作成します。モックオブジェクトを使用すると、テストはその依存関係への呼び出しによって返される値を制御できるため、依存関係に加えられた変更から現在のテストを独立させることができます。これにより、テストのセットアップも簡単になり、テスト対象のモジュール内のコードだけをテストすることができます。 +Unit tests exercise a single unit of code (component, page, service, pipe, etc) in isolation from the rest of the system. Isolation is achieved through the injection of mock objects in place of the code's dependencies. The mock objects allow the test to have fine-grained control of the outputs of the dependencies. The mocks also allow the test to determine which dependencies have been called and what has been passed to them. -モックを使用すると、モックが呼び出されたかどうか、および `toHaveBeenCalled*` セットの関数を介してどのように呼び出されたかを判断するために、テストでモックを確認することもできます。これらの関数では、メソッドが呼び出されたことをテストするときに、`toHaveBeenCalled` メソッドの呼び出しよりも `toHaveBeenCalledTimes` の呼び出しを優先して、テストをできるだけ具体的に行う必要があります。つまり、`expect(mock.foo).toHaveBeenCalledTimes(1)` は `expect(mock.foo).toHaveBeenCalled()` よりも優れています。何かが呼ばれていないこと(`expect(mock.foo).not.toHaveBeenCalled()`)をテストする際は、逆のアドバイスに従うべきです。 +Well-written unit tests are structured such that the unit of code and the features it contains are described via `describe()` callbacks. The requirements for the unit of code and its features are tested via `it()` callbacks. When the descriptions for the `describe()` and `it()` callbacks are read, they make sense as a phrase. When the descriptions for nested `describe()`s and a final `it()` are concatenated together, they form a sentence that fully describes the test case. -Jasmine でモックオブジェクトを作成する一般的な方法は 2 つあります。モックオブジェクトは、`jasmine.createSpy` と`jasmine.createSpyObj` を使ってスクラッチで作成することも、`spyOn()` と `spyOnProperty()` を使って既存のオブジェクトにスパイをインストールすることもできます。 +Since unit tests exercise the code in isolation, they are fast, robust, and allow for a high degree of code coverage. -#### `jasmine.createSpy` と `jasmine.createSpyObj` の利用 {/* #using-jasminecreatespy-and-jasminecreatespyobj */} +Vitest's test functions (`describe`, `it`, `expect`, `beforeEach`, `vi`, and so on) are available as globals in spec files, so they do not need to be imported. Global setup for the unit tests goes in `src/test-setup.ts`, which already polyfills `window.matchMedia` for jsdom because Ionic needs it. The Vitest configuration that the builder uses is in `vitest.config.mts`. -`jasmine.createSpyObj` は、作成時に定義された一連のモックメソッドを使用して、完全なモックオブジェクトをスクラッチで作成します。これはとてもシンプルで便利です。テストのために何かを組み立てたり注入したりする必要はありません。この関数の使用する欠点は、実際のオブジェクトと一致しないオブジェクトを生成できることです。 +### Using Mocks {/* #using-mocks */} -`jasmine.createSpy` も似ていますが、スタンドアロンのモック関数を作成します。 +Unit tests exercise a code module in isolation. To facilitate this, Vitest can create mock functions to take the place of dependencies while testing. When a mock is used, the test can control the values returned by calls to that dependency, making the current test independent of changes made to the dependency. This also makes the test setup easier, allowing the test to only be concerned with the code within the module under test. -#### `spyOn()` と `spyOnProperty()` の利用 {/* #using-spyon-and-spyonproperty */} +Using mocks also allows the test to query the mock to determine if it was called and how it was called via the `toHaveBeenCalled*` set of functions. Tests should be as specific as possible with these functions, favoring calls to `toHaveBeenCalledTimes` over calls to `toHaveBeenCalled` when testing that a method has been called. That is, `expect(mock.foo).toHaveBeenCalledTimes(1)` is better than `expect(mock.foo).toHaveBeenCalled()`. The opposite advice should be followed when testing that something has not been called (`expect(mock.foo).not.toHaveBeenCalled()`). -`spyOn()` は、既存のオブジェクトにスパイをインストールします。この手法を使用する利点は、オブジェクト上に存在しないメソッドをスパイしようとすると、例外が発生することです。これにより、テストが存在しないメソッドをモックすることを防ぎます。欠点は、テストが最初から完全に整形されたオブジェクトを必要とすることであり、これはテストに必要なセットアップの量を増加させるかと思います。 +There are two common ways to create mocks in Vitest. Mock functions can be created from scratch using `vi.fn()`, or spies can be installed onto existing objects using `vi.spyOn()`. Refer to Vitest's [Mocking guide](https://vitest.dev/guide/mocking) for more details. -`spyOnProperty()` は似ていますが、メソッドではなくプロパティに対してスパイするという点で異なります。 +#### Using `vi.fn()` {/* #using-vi-fn */} -### 一般的なテストの構成 {/* #general-testing-structure */} +`vi.fn()` creates a stand-alone mock function. Combining several of them into an object creates a full mock object from scratch, which can be provided in place of a real dependency. This is useful in that it is very simple. Nothing needs to be constructed or injected into the test. The disadvantage of this approach is that it allows the creation of objects that may not match the real objects. -ユニットテストは、エンティティ(Component、Page、Service、Pipe など)ごとに 1 つの `spec` ファイルを持つ `spec` ファイルに含まれています。`spec` ファイルは、テスト中のソースと一緒に存在し、かつその名前が付けられます。たとえば、プロジェクトに WeatherService という Service がある場合、そのコードは`weather.service.ts` という名前のファイルにあり、テストは `weather.service.spec.ts` という名前のファイルにあります。これらのファイルは両方とも同じフォルダにあります。 +#### Using `vi.spyOn()` {/* #using-vi-spyon */} -`spec` ファイル自体には、そのテスト全体を定義するただ一つの `describe` コールが含まれています。その中には、主要な機能領域を定義する他の `describe` コールがネストされています。各 `describe` コールには、setup コードと teardown コード(一般的に `beforeEach` と `afterEach` コールによって処理される)、機能を階層的に分解した `describe` コール、また個々のテストケースを定義する `it` コールが含まれます。 +`vi.spyOn()` installs the spy on an existing object. The advantage of using this technique is that if an attempt is made to spy on a method that does not exist on the object, an exception is raised. This prevents the test from mocking methods that do not exist. The disadvantage is that the test needs a fully formed object to begin with, which may increase the amount of test setup required. -`describe` と `it` コールには、説明のテキストラベルも含まれます。適切な形式のテストでは、`describe` と `it` をコールすると、ラベルと組み合わせた適切なフレーズが実行され、各テストケースのすべてのラベルが `describe` と `it` ラベルを組み合わせて構成され、完全な文が作成されます。 +`vi.spyOn()` can also spy on a getter or setter by passing `'get'` or `'set'` as its third argument. -例: +### General Testing Structure {/* #general-testing-structure */} -```tsx +Unit tests are contained in `spec` files with one `spec` file per entity (component, page, service, pipe, etc.). The `spec` files live side-by-side with and are named after the source that they are testing. For example, if the project has a service called WeatherService, the code for it is in a file named `weather.service.ts` with the tests in a file named `weather.service.spec.ts`. Both of those files are in the same folder. + +The `spec` files themselves contain a single `describe` call that defines that overall test. Nested within it are other `describe` calls that define major areas of functionality. Each `describe` call can contain setup and teardown code (generally handled via `beforeEach` and `afterEach` calls), more `describe` calls forming a hierarchical breakdown of functionality, and `it` calls which define individual test cases. + +The `describe` and `it` calls also contain a descriptive text label. In well-formed tests, the `describe` and `it` calls combine with their labels to form proper phrases and the full label for each test case, formed by combining the `describe` and `it` labels, creates a full sentence. + +For example: + +```ts describe('Calculation', () => { describe('divide', () => { - it('calculates 4 / 2 properly' () => {}); - it('cowardly refuses to divide by zero' () => {}); - ... + it('calculates 4 / 2 properly', () => {}); + it('cowardly refuses to divide by zero', () => {}); + // ... }); describe('multiply', () => { - ... + // ... }); }); ``` -外側の `describe` コールは `Calculation` Service がテストされていることを示し、内側の `describe` コールはテストされている機能を正確に示し、そして `it` コールはテストケースが何であるかを示しています。各テストケースの完全なラベルを実行すると、意味のある文になります(卑劣な 0 での除算という計算を拒否しました)。 +The outer `describe` call states that the `Calculation` service is being tested, the inner `describe` calls state exactly what functionality is being tested, and the `it` calls state what the test cases are. When run, the full label for each test case is a sentence that makes sense (Calculation divide cowardly refuses to divide by zero). -### ページとコンポーネント {/* #pages-and-components */} +### Pages and Components {/* #pages-and-components */} -Pages は単なる Angular コンポーネントです。そのため、ページとコンポーネントは両方とも Angular のコンポーネントテストガイドライン を使ってテストされます。 +Pages are just Angular components. Thus, pages and components are both tested using [Angular's Component Testing](https://angular.dev/guide/testing/components-basics) guidelines. -ページとコンポーネントには TypeScript コードと HTML テンプレートマークアップの両方が含まれているため、コンポーネントクラスのテストとコンポーネント DOM のテストの両方を実行できます。ページが作成されると、生成されるテンプレートテストは次のようになります: +Since pages and components contain both TypeScript code and HTML template markup it is possible to perform both component class testing and component DOM testing. When a page is created with `ionic generate page`, the spec that is generated looks like this: -```tsx -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +```ts import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LoginPage } from './login.page'; + +describe('LoginPage', () => { + let component: LoginPage; + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(LoginPage); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); +``` + +The files that `ionic generate page` creates do not pass the project's lint rules yet. Run `npm run lint:fix` to fix their imports, then add code to the page's empty `ngOnInit()` method or remove it along with `implements OnInit`. + +When doing component class testing, the component object is accessed using the component object defined via `component = fixture.componentInstance;`. This is an instance of the component class. When doing DOM testing, the `fixture.nativeElement` property is used. This is the actual `HTMLElement` for the component, which allows the test to use standard HTML API methods such as `HTMLElement.querySelector` in order to examine the DOM. + +If the page or component needs providers, such as a router for a page that contains links, configure them with `TestBed.configureTestingModule` before creating the component: + +```ts +import type { ComponentFixture } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; import { TabsPage } from './tabs.page'; @@ -92,8 +135,8 @@ describe('TabsPage', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [TabsPage], - schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [TabsPage], + providers: [provideRouter([])], }).compileComponents(); fixture = TestBed.createComponent(TabsPage); @@ -107,15 +150,15 @@ describe('TabsPage', () => { }); ``` -コンポーネントクラスのテストを行う場合、コンポーネントオブジェクトは `component=fixture.componentInstance;` によって定義されたコンポーネントオブジェクトを使用してアクセスされます。これはコンポーネントクラスのインスタンスです。DOM テストを行う際には、`fixture.nativeElement` プロパティが使用されます。これはコンポーネントの実際の `HTMLElement`であり、テストで DOM を調べるために `HTMLElement.querySelector` などの標準の HTML API メソッドを使うことを可能にします。 +### Waiting for Components {/* #waiting-for-components */} -### コンポーネントの待機 {/* #waiting-for-components */} +When you need to wait for an Ionic component to render before asserting against its DOM, use the `componentOnReady` helper exported from `@ionic/core`. Do not call `el.componentOnReady()` directly. `@ionic/angular` uses Stencil's custom elements build, where that method does not exist on the element; it only exists in apps that use the lazy-loaded build from `@ionic/angular/lazy`. The helper handles both. It awaits the element's own `componentOnReady()` promise when that exists, and otherwise waits one animation frame, giving the component's inner contents a chance to render. Wait for the callback before asserting against the rendered DOM or running accessibility tests. -Ionic コンポーネントをテストする際は、`@ionic/core`からエクスポートされる`componentOnReady`ヘルパーを使用し、直接`el.componentOnReady()`を呼び出さないでください。`el.componentOnReady()`メソッドは遅延読み込みされた要素にのみ存在し、カスタム要素ビルド(スタンドアロンプロジェクトで使用される)で直接呼び出すとエラーが発生します。ヘルパーは両方を処理します。要素自身の`componentOnReady()`プロミスが存在する場合はそれを待機します。存在しない場合は 1 フレームのアニメーションを待機し、コンポーネントの内部コンテンツがレンダリングされる機会を与えます。レンダリングされた DOM に対してアサーションを行う前またはアクセシビリティテストを実行する前に、コールバックが完了するのを待ってください。 - -```tsx -import { ComponentFixture, TestBed } from '@angular/core/testing'; +```ts +import type { ComponentFixture } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { componentOnReady } from '@ionic/core'; + import { HomePage } from './home.page'; describe('HomePage', () => { @@ -139,111 +182,115 @@ describe('HomePage', () => { ## Services {/* #services */} -Service は、多くの場合、計算やその他の操作を実行するユーティリティの service と、主に HTTP 操作やデータ操作を実行するデータの service の 2 つの大まかなカテゴリーのいずれかに分類されます。 +Services often fall into one of two broad categories: utility services that perform calculations and other operations, and data services that perform primarily HTTP operations and data manipulation. -### 基本的な Service のテスト {/* #basic-service-testing */} +### Basic Service Testing {/* #basic-service-testing */} -ほとんどの service をテストするために推奨する方法は、service をインスタンス化し、service が持つ依存関係のモックを手動で注入することです。こうすることで、コードを分離してテストすることができます。 +The suggested way to test most services is to provide mocks for any dependency the service has, then get the service from Angular's `TestBed`. This way, the code can be tested in isolation. -たとえば、タイムカードの配列を取得して差引支給額を計算するメソッドを持つ service があるとします。また税金計算は、現在の service が依存しているもう一つの service を介して処理されるとします。この給与計算の service は、このようにテストすることができます: +Let's say that there is a service with a method that takes an array of timecards and calculates net pay. Let's also assume that the tax calculations are handled via another service that the current service depends on, which it gets with `inject(TaxService)`. This payroll service could be tested as such: + +```ts +import { TestBed } from '@angular/core/testing'; +import type { Mock } from 'vitest'; -```tsx import { PayrollService } from './payroll.service'; +import { TaxService } from './tax.service'; describe('PayrollService', () => { let service: PayrollService; - let taxServiceSpy; - - beforeEach(() => { - taxServiceSpy = jasmine.createSpyObj('TaxService', { - federalIncomeTax: 0, - stateIncomeTax: 0, - socialSecurity: 0, - medicare: 0 - }); - service = new PayrollService(taxServiceSpy); - }); - - describe('net pay calculations', () => { - ... - }); + let taxServiceMock: { [K in keyof TaxService]: Mock }; + + beforeEach(() => { + taxServiceMock = { + federalIncomeTax: vi.fn().mockReturnValue(0), + stateIncomeTax: vi.fn().mockReturnValue(0), + socialSecurity: vi.fn().mockReturnValue(0), + medicare: vi.fn().mockReturnValue(0), + }; + + TestBed.configureTestingModule({ + providers: [{ provide: TaxService, useValue: taxServiceMock }], + }); + service = TestBed.inject(PayrollService); + }); + + describe('net pay calculations', () => { + it('subtracts the federal income tax from the gross pay', () => { + taxServiceMock.federalIncomeTax.mockReturnValue(73.24); + + expect(service.netPay([{ hours: 40, rate: 10 }])).toBeCloseTo(326.76); + expect(taxServiceMock.federalIncomeTax).toHaveBeenCalledTimes(1); + expect(taxServiceMock.federalIncomeTax).toHaveBeenCalledWith(400); + }); + }); }); ``` -これにより、テストでは `taxServiceSpy.federalIncomeTax.and.returnValue(73.24)` などのモックの設定を介して様々な税金計算から戻される値を制御できます。これにより、「差引支給額」のテストを税金計算ロジックから独立させることができます。税金のコードが変更された場合、修正する必要があるのは税金 service 関連のコードとテストのみです。差引支給額のテストは、税金がどのように計算されるかを考慮せず、値が適切に適用されるのみであるため、そのまま機能し続けることができます。 +This allows the test to control the values returned by the various tax calculations via mock setup such as `taxServiceMock.federalIncomeTax.mockReturnValue(73.24)`. This allows the "net pay" tests to be independent of the tax calculation logic. When the tax codes change, only the tax service related code and tests need to change. The tests for the net pay can continue to operate as they are since these tests do not care how the tax is calculated, just that the value is applied properly. -`ionic g service name` で service を生成するときに使われる scaffold は Angular のテストユーティリティを使ってテストモジュールをセットアップします。必ずしもそうする必要はありません。ただし、このコードを残しておくことで、手動でサービスを構築したり、次のように注入したりすることができます: +To keep the real tax service and only replace one of its methods, get both services from the `TestBed` and spy on the method instead. This works without listing any providers because both services are `@Injectable({ providedIn: 'root' })`, which is how `ionic generate service` creates them: -```tsx -import { TestBed, inject } from '@angular/core/testing'; +```ts +import { TestBed } from '@angular/core/testing'; import { PayrollService } from './payroll.service'; import { TaxService } from './tax.service'; -describe('PayrolService', () => { - let taxServiceSpy; +describe('PayrollService', () => { + let service: PayrollService; + let taxService: TaxService; beforeEach(() => { - taxServiceSpy = jasmine.createSpyObj('TaxService', { - federalIncomeTax: 0, - stateIncomeTax: 0, - socialSecurity: 0, - medicare: 0, - }); - TestBed.configureTestingModule({ - providers: [PayrollService, { provide: TaxService, useValue: taxServiceSpy }], - }); + service = TestBed.inject(PayrollService); + taxService = TestBed.inject(TaxService); }); - it('does some test where it is injected', inject([PayrollService], (service: PayrollService) => { - expect(service).toBeTruthy(); - })); + it('uses the federal income tax from the tax service', () => { + vi.spyOn(taxService, 'federalIncomeTax').mockReturnValue(73.24); + + service.netPay([{ hours: 40, rate: 10 }]); - it('does some test where it is manually built', () => { - const service = new PayrollService(taxServiceSpy); - expect(service).toBeTruthy(); + expect(taxService.federalIncomeTax).toHaveBeenCalledTimes(1); }); }); ``` -#### HTTP データ Service のテスト {/* #testing-http-data-services */} +#### Testing HTTP Data Services {/* #testing-http-data-services */} -ほとんどの HTTP 操作を実行するサービスは、これらの操作を行うために Angular の HttpClient サービスを使用します。そのようなテストには、Angular の`HttpClientTestingModule`を使用することが推奨されています。このモジュールの詳細なドキュメントについては、Angular のAngular の HTTP リクエストテストガイドを参照してください。 +Most services that perform HTTP operations will use Angular's `HttpClient` service in order to perform those operations. For such tests, it is suggested to provide `HttpClient` with `provideHttpClient()` and replace its backend with `provideHttpClientTesting()`. The test then uses `HttpTestingController` to check the requests that were made and to respond to them. For detailed documentation, please refer to Angular's [Testing HTTP requests](https://angular.dev/guide/http/testing) guide. -このようなテストの基本的な設定は次のようになります: +This basic setup for such a test looks like this: -```tsx -import { HttpBackend, HttpClient } from '@angular/common/http'; -import { HttpTestingController, HttpClientTestingModule } from '@angular/common/http/testing'; -import { TestBed, inject } from '@angular/core/testing'; +```ts +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; import { IssTrackingDataService } from './iss-tracking-data.service'; describe('IssTrackingDataService', () => { - let httpClient: HttpClient; let httpTestingController: HttpTestingController; - let issTrackingDataService: IssTrackingDataService; + let service: IssTrackingDataService; beforeEach(() => { TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [IssTrackingDataService], + providers: [provideHttpClient(), provideHttpClientTesting()], }); - httpClient = TestBed.get(HttpClient); - httpTestingController = TestBed.get(HttpTestingController); - issTrackingDataService = new IssTrackingDataService(httpClient); + httpTestingController = TestBed.inject(HttpTestingController); + service = TestBed.inject(IssTrackingDataService); }); - it('exists', inject([IssTrackingDataService], (service: IssTrackingDataService) => { - expect(service).toBeTruthy(); - })); + afterEach(() => { + httpTestingController.verify(); + }); describe('location', () => { it('gets the location of the ISS now', () => { - issTrackingDataService.location().subscribe((x) => { - expect(x).toEqual({ longitude: -138.1719, latitude: 44.4423 }); - }); + let location: unknown; + service.location().subscribe((value) => (location = value)); + const req = httpTestingController.expectOne('http://api.open-notify.org/iss-now.json'); expect(req.request.method).toEqual('GET'); req.flush({ @@ -251,7 +298,8 @@ describe('IssTrackingDataService', () => { timestamp: 1525950644, message: 'success', }); - httpTestingController.verify(); + + expect(location).toEqual({ longitude: -138.1719, latitude: 44.4423 }); }); }); }); @@ -259,14 +307,14 @@ describe('IssTrackingDataService', () => { ### Pipes {/* #pipes */} -pipe は、特別に定義されたインタフェースを持つ service のようなものです。このクラスには、入力値(およびその他のオプションの引数)を操作してページにレンダリングされる出力を作成するための public メソッド `transform` が含まれています。パイプをテストするには、パイプをインスタンス化し、transform メソッドを呼び出して結果を検証します。 +A pipe is like a service with a specifically defined interface. It is a class that contains one public method, `transform`, which manipulates the input value (and other optional arguments) in order to create the output that is rendered on the page. To test a pipe: instantiate the pipe, call the transform method, and verify the results. -簡単な例として、`Person`オブジェクトを受け取り名前をフォーマットするパイプを考えてみましょう。簡単のために、`Person`は`id`、`firstName`、`lastName`、および`middleInitial`で構成されているとします。このパイプの要件は、名前が存在しない場合でも「姓, 名 M.」の形式で名前を表示することです。例えば、このようなテストは以下のようになります: +As a simple example, let's consider a pipe that takes a `Person` object and formats the name. For the sake of simplicity, let's say a `Person` consists of an `id`, `firstName`, `lastName`, and `middleInitial`. The requirements for the pipe are to print the name as "Last, First M." handling situations where a first name, last name, or middle initial do not exist. Such a test might look like this: -```tsx -import { NamePipe } from './name.pipe'; +```ts +import type { Person } from '../models/person'; -import { Person } from '../../models/person'; +import { NamePipe } from './name.pipe'; describe('NamePipe', () => { let pipe: NamePipe; @@ -282,279 +330,161 @@ describe('NamePipe', () => { }; }); - it('exists', () => { - expect(pipe).toBeTruthy(); - }); - it('formats a full name properly', () => { - expect(pipe.transform(testPerson)).toBeEqual('Adams, Douglas N.'); + expect(pipe.transform(testPerson)).toBe('Adams, Douglas N.'); }); it('handles having no middle initial', () => { delete testPerson.middleInitial; - expect(pipe.transform(testPerson)).toBeEqual('Adams, Douglas'); + expect(pipe.transform(testPerson)).toBe('Adams, Douglas'); }); it('handles having no first name', () => { delete testPerson.firstName; - expect(pipe.transform(testPerson)).toBeEqual('Adams N.'); + expect(pipe.transform(testPerson)).toBe('Adams N.'); }); it('handles having no last name', () => { delete testPerson.lastName; - expect(pipe.transform(testPerson)).toBeEqual('Douglas N.'); + expect(pipe.transform(testPerson)).toBe('Douglas N.'); }); }); ``` -また、pipe を利用するコンポーネントおよびページでの DOM テストを介して pipe を実行することも有益です。 +It is also beneficial to exercise the pipe via DOM testing in the components and pages that utilize the pipe. -## エンドツーエンドテスト {/* #end-to-end-testing */} +## End-to-end Testing {/* #end-to-end-testing */} -エンドツーエンドのテストは、アプリケーションが全体として機能し、多くの場合、ライブデータへの接続を含むことを検証するために使用されます。一方で、ユニットテストは分離されたコードユニットに重点を置いているため、アプリケーションロジックの低レベルのテストが可能ですが、エンドツーエンドテストはさまざまなユーザーストーリーや使用・シナリオに重点を置いており、アプリケーション全体を通したデータフローの総合的な高レベルのテストを提供します。また一方で、ユニットテストではアプリケーションのロジックの問題を明らかにしようとしますが、エンドツーエンドテストでは、個々のユニットが一緒に使用される場合に発生する問題を明らかにしようとします。エンドツーエンドのテストにより、アプリケーションの全体的なアーキテクチャに関する問題が明らかになります。 +End-to-end testing is used to verify that an application works as a whole and often includes a connection to live data. Whereas unit tests focus on code units in isolation and thus allow for low-level testing of the application logic, end-to-end tests focus on various user stories or usage scenarios, providing high-level testing of the overall flow of data through the application. Whereas unit tests try to uncover problems with an application's logic, end-to-end tests try to uncover problems that occur when those individual units are used together. End-to-end tests uncover problems with the overall architecture of the application. -エンドツーエンドテストはユーザーストーリーを実行し、個々のコードモジュールではなくアプリケーション全体を対象とするため、エンドツーエンドテストは、メインアプリケーション自体のコードとは別に、プロジェクト内の独自のアプリケーションとして存在します。ほとんどのエンドツーエンドテストは、アプリケーションとの共通のユーザー対話を自動化し、それらの対話の結果を判別するために DOM を調査します。 +Since end-to-end tests exercise user stories and cover the application as a whole rather than individual code modules, they run in a real browser against the running application. Most end-to-end tests operate by automating common user interactions with the application and examining the DOM to determine the results of those interactions. -### テストの構成 {/* #test-structure */} +### Test Structure {/* #test-structure */} -`@ionic/angular` アプリケーションが作成されると、 デフォルトのエンドツーエンドのテストアプリケーションが `e2e` フォルダに生成されます。このアプリケーションは Protractor を使用してブラウザを制御し、Jasmine を使用してテストを構築し、実行します。アプリケーションは、初期時は次の 4 つのファイルで構成されています: +When an `@ionic/angular` application is generated, it is set up to run end-to-end tests with [Playwright](https://playwright.dev). The setup consists of: -- `protractor.conf.js` - Protractor の設定ファイル -- `tsconfig.e2e.json` - テストアプリケーション用の特定の TypeScript の設定 -- `src/app.po.ts` - アプリケーションをナビゲートするメソッド、DOM 内の要素を照会するメソッド、ページ上の要素を操作するメソッドを含むページオブジェクト -- `src/app.e2e-spec.ts` - テスト用のスクリプト +- `playwright.config.ts` - the Playwright configuration file. It starts the app with `npm start` before the tests run and emulates a mobile device (a Pixel 7) in Chromium. +- `e2e/app.spec.ts` - a testing script that loads the app and checks its first page. -#### ページオブジェクト {/* #page-objects */} +Refer to Playwright's [Writing tests](https://playwright.dev/docs/writing-tests) guide for an introduction to the test API. -エンドツーエンドのテストは、アプリケーションとの共通のユーザー対話を自動化し、アプリケーションが応答するのを待ち、対話の結果を判別するために DOM を検査します。これには、多くの DOM 操作と試験が必要です。これらをすべて手作業で行うと、テストは非常に脆くなり、見て理解することや保守が困難になります。 +#### Page Objects {/* #page-objects */} -ページオブジェクトは、TypeScript クラスの単一ページの HTML をカプセル化し、テスト用のスクリプトがアプリケーションと対話するために使用する API を提供します。DOM 操作ロジックをページオブジェクト内にカプセル化することで、テストが読みやすくなり、かつ判断することがはるかに簡単になり、テストの保守コストが大幅に削減されます。洗練されたページオブジェクトを作成することは、高品質で保守しやすいエンドツーエンドのテストを作成するための鍵です。 +End-to-end tests operate by automating common user interactions with the application, waiting for the application to respond, and examining the DOM to determine the results of the interaction. This involves a lot of DOM manipulation and examination. If this were all done manually, the tests would be very brittle and difficult to read and maintain. -##### ベースページオブジェクト {/* #base-page-object */} +Page objects encapsulate the HTML for a single page in a TypeScript class, providing an API that the test scripts use to interact with the application. The encapsulation of the DOM manipulation logic in page objects makes the tests more readable and far easier to reason about, lowering the maintenance costs of the test. Creating well-crafted page objects is the key to creating high quality and maintainable end-to-end tests. Refer to Playwright's [Page object models](https://playwright.dev/docs/pom) guide for more details. -多くのテストは、ページが表示されるのを待ったり、input にテキストを入力したり、ボタンをクリックするなどのアクションに依存しています。これを行うために使用されるメソッドは、適切な DOM 要素の変更を取得するために使用される CSS セレクターのみと一貫性があります。したがって、このロジックを、他のページオブジェクトが使用できるベースクラスに抽象化することは理にかなっています。 +Playwright's [locators](https://playwright.dev/docs/locators) wait for elements automatically, so a page object can mostly return locators and leave the waiting to the assertions that use them. -すべてのページオブジェクトがサポートを必要とするいくつかのベースメソッドを実装する例を次に示します: +Here is an example page object for the tabs in an app created from the `tabs` starter: -```tsx -import { browser, by, element, ExpectedConditions } from 'protractor'; +```ts title="e2e/page-objects/tabs.po.ts" +import type { Locator, Page } from '@playwright/test'; -export class PageObjectBase { - private path: string; - protected tag: string; +export class TabsPage { + constructor(private readonly page: Page) {} - constructor(tag: string, path: string) { - this.tag = tag; - this.path = path; + async load(): Promise { + await this.page.goto('/'); } - load() { - return browser.get(this.path); + // Only match the title of the page that is showing. + title(): Locator { + return this.page.locator('ion-title').filter({ visible: true }).first(); } - rootElement() { - return element(by.css(this.tag)); + tabButton(name: string): Locator { + return this.page.getByRole('tab', { name }); } - waitUntilInvisible() { - browser.wait(ExpectedConditions.invisibilityOf(this.rootElement()), 3000); - } - - waitUntilPresent() { - browser.wait(ExpectedConditions.presenceOf(this.rootElement()), 3000); - } - - waitUntilNotPresent() { - browser.wait(ExpectedConditions.not(ExpectedConditions.presenceOf(this.rootElement())), 3000); - } - - waitUntilVisible() { - browser.wait(ExpectedConditions.visibilityOf(this.rootElement()), 3000); - } - - getTitle() { - return element(by.css(`${this.tag} ion-title`)).getText(); - } - - protected enterInputText(sel: string, text: string) { - const el = element(by.css(`${this.tag} ${sel}`)); - const inp = el.element(by.css('input')); - inp.sendKeys(text); - } - - protected enterTextareaText(sel: string, text: string) { - const el = element(by.css(`${this.tag} ${sel}`)); - const inp = el.element(by.css('textarea')); - inp.sendKeys(text); - } - - protected clickButton(sel: string) { - const el = element(by.css(`${this.tag} ${sel}`)); - browser.wait(ExpectedConditions.elementToBeClickable(el)); - el.click(); + async selectTab(name: string): Promise { + await this.tabButton(name).click(); } } ``` -##### ページ毎の要約 {/* #per-page-abstractions */} - -アプリケーションの各ページには、そのページの要素を抽象化する独自のページオブジェクトクラスがあります。ベースとなるページオブジェクトクラスを使用する場合、ページオブジェクトを作成するには、ほとんどの場合そのページに固有の要素のカスタムメソッドを作成する必要があります。多くの場合、これらのカスタム要素は、必要な作業を実行するためにベースクラスのメソッドの恩恵を受けます。 - -次に、単純ですが典型的なログインページのページオブジェクトの例を示します。`enterEMail()` のような多くのメソッドは、作業の大部分を行うベースクラスのメソッドを呼び出すことに注意してください。 - -```tsx -import { browser, by, element, ExpectedConditions } from 'protractor'; -import { PageObjectBase } from './base.po'; - -export class LoginPage extends PageObjectBase { - constructor() { - super('app-login', '/login'); - } - - waitForError() { - browser.wait(ExpectedConditions.presenceOf(element(by.css('.error'))), 3000); - } - - getErrorMessage() { - return element(by.css('.error')).getText(); - } - - enterEMail(email: string) { - this.enterInputText('#email-input', email); - } - - enterPassword(password: string) { - this.enterInputText('#password-input', password); - } +:::note +Ionic keeps pages in the navigation stack, and tabs that have been visited, in the DOM after navigating away from them, hiding them instead of removing them. Use `filter({ visible: true })` when the same element, such as `ion-title`, can appear on more than one page. +::: - clickSignIn() { - this.clickButton('#signin-button'); - } -} -``` +#### Testing Scripts {/* #testing-scripts */} -#### テストスクリプト {/* #testing-scripts */} +Similar to unit tests, end-to-end test scripts consist of nested `test.describe()` and `test()` functions. In the case of end-to-end tests, the `test.describe()` functions generally denote specific scenarios with the `test()` functions denoting specific behaviors that should be exhibited by the application as actions are performed within that scenario. -ユニットテストと同様に、エンドツーエンドのテストスクリプトはネストされた `describe()` と `it()` 関数で構成されています。エンドツーエンドのテストの場合、`describe()` 関数は一般に、特定のシナリオを、そのシナリオ内でアクションが実行されるときにアプリケーションによって表されるべき特定の振る舞いを示す `it()` 関数とともに示します。 +Also similar to unit tests, the labels used in the `test.describe()` and `test()` functions should make sense when concatenated together to form the complete test case. -また、ユニットテストと同様に、`describe()` および `it()` 関数で使用されるラベルは、"describe" または "it"と、完全なテストケースを形成するためにともに連結されるとき、両方とも意味をなします。 +Here is a sample end-to-end test script that uses the page object above to exercise the tab navigation: -典型的なログインシナリオを実行するエンドツーエンドのテストスクリプトの簡単な例を次に示します。 +```ts title="e2e/tabs.spec.ts" +import { expect, test } from '@playwright/test'; -```tsx -import { AppPage } from '../page-objects/pages/app.po'; -import { AboutPage } from '../page-objects/pages/about.po'; -import { CustomersPage } from '../page-objects/pages/customers.po'; -import { LoginPage } from '../page-objects/pages/login.po'; -import { MenuPage } from '../page-objects/pages/menu.po'; -import { TasksPage } from '../page-objects/pages/tasks.po'; +import { TabsPage } from './page-objects/tabs.po'; -describe('Login', () => { - const about = new AboutPage(); - const app = new AppPage(); - const customers = new CustomersPage(); - const login = new LoginPage(); - const menu = new MenuPage(); - const tasks = new TasksPage(); +test.describe('Tabs', () => { + let tabs: TabsPage; - beforeEach(() => { - app.load(); + test.beforeEach(async ({ page }) => { + tabs = new TabsPage(page); + await tabs.load(); }); - describe('before logged in', () => { - it('displays the login screen', () => { - expect(login.rootElement().isDisplayed()).toEqual(true); - }); - - it('allows in-app navigation to about', () => { - menu.clickAbout(); - about.waitUntilVisible(); - login.waitUntilInvisible(); - }); - - it('does not allow in-app navigation to tasks', () => { - menu.clickTasks(); - app.waitForPageNavigation(); - expect(login.rootElement().isDisplayed()).toEqual(true); - }); - - it('does not allow in-app navigation to customers', () => { - menu.clickCustomers(); - app.waitForPageNavigation(); - expect(login.rootElement().isDisplayed()).toEqual(true); - }); - - it('displays an error message if the login fails', () => { - login.enterEMail('test@test.com'); - login.enterPassword('bogus'); - login.clickSignIn(); - login.waitForError(); - expect(login.getErrorMessage()).toEqual('The password is invalid or the user does not have a password.'); + test.describe('on first load', () => { + test('shows the first tab', async ({ page }) => { + await expect(page).toHaveURL(/\/tabs\/tab1$/); + await expect(tabs.title()).toHaveText('Tab 1'); }); - it('navigates to the tasks page if the login succeeds', () => { - login.enterEMail('test@test.com'); - login.enterPassword('testtest'); - login.clickSignIn(); - tasks.waitUntilVisible(); + test('marks the first tab as selected', async () => { + await expect(tabs.tabButton('Tab 1')).toHaveAttribute('aria-selected', 'true'); }); }); - describe('once logged in', () => { - beforeEach(() => { - tasks.waitUntilVisible(); - }); + test.describe('when switching tabs', () => { + test('navigates to the selected tab', async ({ page }) => { + await tabs.selectTab('Tab 2'); - it('allows navigation to the customers page', () => { - menu.clickCustomers(); - customers.waitUntilVisible(); - tasks.waitUntilInvisible(); + await expect(page).toHaveURL(/\/tabs\/tab2$/); + await expect(tabs.title()).toHaveText('Tab 2'); }); - it('allows navigation to the about page', () => { - menu.clickAbout(); - about.waitUntilVisible(); - tasks.waitUntilInvisible(); - }); + test('navigates back to a previous tab', async ({ page }) => { + await tabs.selectTab('Tab 3'); + await tabs.selectTab('Tab 1'); - it('allows navigation back to the tasks page', () => { - menu.clickAbout(); - tasks.waitUntilInvisible(); - menu.clickTasks(); - tasks.waitUntilVisible(); + await expect(page).toHaveURL(/\/tabs\/tab1$/); + await expect(tabs.title()).toHaveText('Tab 1'); }); }); }); ``` -### 設定 {/* #configuration */} +### Configuration {/* #configuration */} -デフォルトの設定では、開発に使用される同じ `environment.ts` ファイルを使います。エンドツーエンドのテストで使用するデータをより適切に制御するには、テスト用の特定の環境を用意し、テストにその環境を使用すると便利なことが多いです。このセクションでは、この設定を作成する 1 つの方法を示します。 +The default configuration uses the same `environment.ts` file that is used for development. In order to provide better control over the data used by the end-to-end tests, it is often useful to create a specific environment for testing and use that environment for the tests. This section shows one possible way to create this configuration. -#### テスト環境 {/* #testing-environment */} +#### Testing Environment {/* #testing-environment */} -テスト環境を設定するには、テスト専用のバックエンドを使用する新しい環境ファイルを作成し、その環境を使うために `angular.json` ファイルを更新し、`package.json` 中の `e2e` スクリプトを `test` 環境を指定するように修正します。 +Setting up a testing environment involves creating a new environment file that uses a dedicated testing backend, updating the `angular.json` file to use that environment, and modifying the `webServer` command in `playwright.config.ts` to serve the app with it. -##### `environment.e2e.ts` ファイルを生成 {/* #create-the-environmente2ets-file */} +##### Create the `environment.e2e.ts` File {/* #create-the-environmente2ets-file */} -Angular の `environment.ts` と `environment.prod.ts` ファイルは、アプリケーションのバックエンドのデータサービスのベース URL などの情報を格納するために度々使用されます。また、同じ情報を提供する `environment.e2e.ts` を作成してください。これは、開発または本番のバックエンドサービスではなく、テスト専用のバックエンドサービスにのみ接続します。以下に例を示します: +The Angular `environment.ts` and `environment.prod.ts` files are often used to store information such as the base URL for the application's backend data services. Create an `environment.e2e.ts` that provides the same information, only connecting to backend services that are dedicated to testing rather than the development or production backend services. Here is an example: -```tsx +```ts title="src/environments/environment.e2e.ts" export const environment = { production: false, - databaseURL: 'https://e2e-test-api.my-great-app.com', - projectId: 'my-great-app-e2e', + apiUrl: 'https://e2e-test-api.my-great-app.com', }; ``` -##### `angular.json` ファイルを修正 {/* #modify-the-angularjson-file */} +##### Modify the `angular.json` File {/* #modify-the-angularjson-file */} -`angular.json` ファイルを使用するには、このファイルを修正する必要があります。これは階層化プロセスです。以下の XPath リストに従って、必要な設定を追加しましょう。 +The `angular.json` file needs to be modified to use this file. Follow the paths listed below to add the configuration that is required. -`/projects/app/architect/build/configurations` にファイルの置換を行う `test` という名前の設定を追加します: +Add a configuration at `projects.app.architect.build.configurations` called `e2e` that does the file replacement: ```json -"test": { +"e2e": { "fileReplacements": [ { "replace": "src/environments/environment.ts", @@ -564,58 +494,53 @@ export const environment = { } ``` -`/projects/app/architect/serve/configurations` に、上記で定義した `test` というビルドの設定をブラウザターゲットに指定する `test` という名前の設定を追加します。 +Add a configuration at `projects.app.architect.serve.configurations` called `e2e` that points the build target at the `development` build configuration combined with the `e2e` build configuration defined above: ```json -"test": { - "browserTarget": "app:build:test" +"e2e": { + "buildTarget": "app:build:development,e2e" } ``` -`/projects/app-e2e/architect/e2e/configurations` に、上記で定義した `test` という起動設定で開発サーバーターゲットを指定する `test` という名前の設定を追加します。 - -```json -"test": { - "devServerTarget": "app:serve:test" -} -``` +##### Modify the `playwright.config.ts` File {/* #modify-the-playwrightconfigts-file */} -##### `package.json` ファイルを修正 {/* #modify-the-packagejson-file */} +Modify `playwright.config.ts` so that `npm run test:e2e` serves the app with the `e2e` configuration. Serve it on a different port than the default `4200`, so Playwright never reuses a development server that is already running, which would send the tests to the development backend: -`npm run e2e` が `test` の設定を使うように `package.json` ファイルを修正します。 +```ts title="playwright.config.ts" +const baseURL = 'http://localhost:4300'; -```json -"scripts": { - "e2e": "ng e2e --configuration=test", - "lint": "ng lint", - "ng": "ng", - "start": "ng serve", - "test": "ng test", - "test:dev": "ng test --browsers=ChromeHeadlessCI", - "test:ci": "ng test --no-watch --browsers=ChromeHeadlessCI" -}, +export default defineConfig({ + // ...existing config... + webServer: { + command: 'npm start -- --configuration=e2e --port 4300', + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); ``` -#### テストクリーンアップ {/* #test-cleanup */} +#### Test Cleanup {/* #test-cleanup */} -エンドツーエンドテストが何らかの方法でデータを変更する場合は、テストが完了したらデータを既知の状態に一度リセットすると便利です。そのための 1 つの方法は: +If the end-to-end tests modify data in any way, it is helpful to reset the data to a known state once the tests complete. One way to do that is to: -1. クリーンアップを実行するエンドポイントを生成する。 -1. `protractor.conf.js` ファイルによってエクスポートされる `config` オブジェクトに `onCleanUp()` 関数を追加する +1. Create an endpoint that performs the cleanup. +1. Add a [global teardown](https://playwright.dev/docs/test-global-setup-teardown) file that calls the endpoint, and point the `globalTeardown` option in `playwright.config.ts` at it. -次に例を示します: +Here is an example: -```javascript -onCleanUp() { - const axios = require('axios'); - return axios - .post( - 'https://e2e-test-api.my-great-app.com/purgeDatabase', - {} - ) - .then(res => { - console.log(res.data); - }) - .catch(err => console.log(err)); +```ts title="e2e/global-teardown.ts" +async function globalTeardown(): Promise { + const response = await fetch('https://e2e-test-api.my-great-app.com/purgeDatabase', { method: 'POST' }); + console.log(await response.text()); } + +export default globalTeardown; +``` + +```ts title="playwright.config.ts" +export default defineConfig({ + // ...existing config... + globalTeardown: './e2e/global-teardown.ts', +}); ``` diff --git a/docs/angular/your-first-app.mdx b/docs/angular/your-first-app.mdx index e384c7d9cf..93254ec778 100644 --- a/docs/angular/your-first-app.mdx +++ b/docs/angular/your-first-app.mdx @@ -111,8 +111,8 @@ import { IonicRouteStrategy, provideIonicAngular } from '@ionic/angular'; // CHANGE: Add the following import import { defineCustomElements } from '@ionic/pwa-elements/loader'; -import { routes } from './app/app.routes'; import { AppComponent } from './app/app.component'; +import { routes } from './app/app.routes'; // CHANGE: Call the element loader before the `bootstrapApplication` call defineCustomElements(window); @@ -191,11 +191,11 @@ ionic serve ```html - Photo Gallery + Photo Gallery - + Photo Gallery @@ -239,4 +239,29 @@ ionic serve ``` +Then open `src/app/tabs/tabs.page.ts` and register the `images` icon in place of `ellipse`, so the new icon can display: + +```ts +import { Component, EnvironmentInjector, inject } from '@angular/core'; +import { IonTabs, IonTabBar, IonTabButton, IonIcon, IonLabel } from '@ionic/angular'; +import { addIcons } from 'ionicons'; +// CHANGE: Update import +import { triangle, images, square } from 'ionicons/icons'; + +@Component({ + selector: 'app-tabs', + templateUrl: 'tabs.page.html', + styleUrls: ['tabs.page.scss'], + imports: [IonTabs, IonTabBar, IonTabButton, IonIcon, IonLabel], +}) +export class TabsPage { + public environmentInjector = inject(EnvironmentInjector); + + constructor() { + // CHANGE: Register the `images` icon instead of `ellipse` + addIcons({ triangle, images, square }); + } +} +``` + これは、Ionic でできるすべての素晴らしいことの始まりに過ぎません。次に、Web でカメラ撮影機能を実装し、その後 iOS と Android 用にビルドします。 diff --git a/docs/angular/your-first-app/2-taking-photos.mdx b/docs/angular/your-first-app/2-taking-photos.mdx index a729c75901..64e4783b13 100644 --- a/docs/angular/your-first-app/2-taking-photos.mdx +++ b/docs/angular/your-first-app/2-taking-photos.mdx @@ -45,12 +45,12 @@ import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; }) export class PhotoService { // CHANGE: Add the gallery method - public async addNewToGallery() { + public async addNewToGallery(): Promise { // Take a photo const capturedPhoto = await Camera.getPhoto({ - resultType: CameraResultType.Uri, - source: CameraSource.Camera, - quality: 100, + resultType: CameraResultType.Uri, // file-based data; provides best performance + source: CameraSource.Camera, // automatically take a new photo with the camera + quality: 100, // highest quality (0 to 100) }); } } @@ -78,6 +78,7 @@ import { // CHANGE: Register the camera icon used by the FAB import { addIcons } from 'ionicons'; import { camera } from 'ionicons/icons'; + // CHANGE: Import the PhotoService import { PhotoService } from '../services/photo.service'; @@ -98,7 +99,7 @@ export class Tab2Page { } // CHANGE: Add `addPhotoToGallery()` method - addPhotoToGallery() { + addPhotoToGallery(): void { this.photoService.addNewToGallery(); } } @@ -166,7 +167,7 @@ export class PhotoService { // CHANGE: Add the `photos` signal public photos = signal([]); - public async addNewToGallery() { + public async addNewToGallery(): Promise { // ...existing code... } } @@ -176,12 +177,12 @@ Over in the `addNewToGallery` method, add the newly captured photo to the beginn ```ts // CHANGE: Update `addNewToGallery()` method -public async addNewToGallery() { +public async addNewToGallery(): Promise { // Take a photo const capturedPhoto = await Camera.getPhoto({ - resultType: CameraResultType.Uri, - source: CameraSource.Camera, - quality: 100 + resultType: CameraResultType.Uri, // file-based data; provides best performance + source: CameraSource.Camera, // automatically take a new photo with the camera + quality: 100, // highest quality (0 to 100) }); // CHANGE: Add the new photo to the front of the photos signal @@ -207,12 +208,12 @@ import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; export class PhotoService { public photos = signal([]); - public async addNewToGallery() { + public async addNewToGallery(): Promise { // Take a photo const capturedPhoto = await Camera.getPhoto({ - resultType: CameraResultType.Uri, - source: CameraSource.Camera, - quality: 100, + resultType: CameraResultType.Uri, // file-based data; provides best performance + source: CameraSource.Camera, // automatically take a new photo with the camera + quality: 100, // highest quality (0 to 100) }); this.photos.update((photos) => [ diff --git a/docs/angular/your-first-app/3-saving-photos.mdx b/docs/angular/your-first-app/3-saving-photos.mdx index 24142ad24c..93d23ab29d 100644 --- a/docs/angular/your-first-app/3-saving-photos.mdx +++ b/docs/angular/your-first-app/3-saving-photos.mdx @@ -47,7 +47,8 @@ We can use this new method immediately in `addNewToGallery()`. ```ts import { Injectable, signal } from '@angular/core'; -import { Camera, CameraResultType, CameraSource, Photo } from '@capacitor/camera'; +import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; +import type { Photo } from '@capacitor/camera'; @Injectable({ providedIn: 'root', @@ -56,12 +57,12 @@ export class PhotoService { public photos = signal([]); // CHANGE: Update the `addNewToGallery()` method - public async addNewToGallery() { + public async addNewToGallery(): Promise { // Take a photo const capturedPhoto = await Camera.getPhoto({ - resultType: CameraResultType.Uri, - source: CameraSource.Camera, - quality: 100, + resultType: CameraResultType.Uri, // file-based data; provides best performance + source: CameraSource.Camera, // automatically take a new photo with the camera + quality: 100, // highest quality (0 to 100) }); // CHANGE: Add `savedImageFile` @@ -93,7 +94,7 @@ Then, pass the data to the Filesystem's `writeFile` method. Recall that we displ Create a new helper method, `base64FromPath()`, to implement the necessary logic for running on the web: ```ts -import { Injectable } from '@angular/core'; +import { Injectable, signal } from '@angular/core'; import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import type { Photo } from '@capacitor/camera'; // CHANGE: Add import @@ -164,12 +165,12 @@ import { Filesystem, Directory } from '@capacitor/filesystem'; export class PhotoService { public photos = signal([]); - public async addNewToGallery() { + public async addNewToGallery(): Promise { // Take a photo const capturedPhoto = await Camera.getPhoto({ - resultType: CameraResultType.Uri, - source: CameraSource.Camera, - quality: 100, + resultType: CameraResultType.Uri, // file-based data; provides best performance + source: CameraSource.Camera, // automatically take a new photo with the camera + quality: 100, // highest quality (0 to 100) }); // Save the picture and add it to photo collection diff --git a/docs/angular/your-first-app/4-loading-photos.mdx b/docs/angular/your-first-app/4-loading-photos.mdx index cd2277f2a5..89d95dfc22 100644 --- a/docs/angular/your-first-app/4-loading-photos.mdx +++ b/docs/angular/your-first-app/4-loading-photos.mdx @@ -33,7 +33,7 @@ export class PhotoService { Next, at the end of the `addNewToGallery()` method, add a call to `Preferences.set()` to save the `photos` array. By adding it here, the `photos` array is stored each time a new photo is taken. This way, it doesn’t matter when the app user closes or switches to a different app - all photo data is saved. ```ts -import { Injectable } from '@angular/core'; +import { Injectable, signal } from '@angular/core'; import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import type { Photo } from '@capacitor/camera'; import { Filesystem, Directory } from '@capacitor/filesystem'; @@ -47,12 +47,12 @@ export class PhotoService { // ...existing code... // CHANGE: Update `addNewToGallery()` method - public async addNewToGallery() { + public async addNewToGallery(): Promise { // Take a photo const capturedPhoto = await Camera.getPhoto({ - resultType: CameraResultType.Uri, - source: CameraSource.Camera, - quality: 100, + resultType: CameraResultType.Uri, // file-based data; provides best performance + source: CameraSource.Camera, // automatically take a new photo with the camera + quality: 100, // highest quality (0 to 100) }); const savedImageFile = await this.savePicture(capturedPhoto); @@ -82,7 +82,7 @@ export class PhotoService { // ...existing code... // CHANGE: Add the method to load the photo data - public async loadSaved() { + public async loadSaved(): Promise { // Retrieve cached photo array data const { value: photoList } = await Preferences.get({ key: this.PHOTO_STORAGE }); this.photos.set((photoList ? JSON.parse(photoList) : []) as UserPhoto[]); @@ -97,7 +97,7 @@ export class PhotoService { // ...existing code... // CHANGE: Update the `loadSaved()` method - public async loadSaved() { + public async loadSaved(): Promise { // Retrieve cached photo array data const { value: photoList } = await Preferences.get({ key: this.PHOTO_STORAGE }); const photos = (photoList ? JSON.parse(photoList) : []) as UserPhoto[]; @@ -105,13 +105,13 @@ export class PhotoService { // CHANGE: Display the photo by reading into base64 format for (const photo of photos) { // Read each saved photo's data from the Filesystem - const file = await Filesystem.readFile({ + const readFile = await Filesystem.readFile({ path: photo.filepath, directory: Directory.Data, }); // Web platform only: Load the photo as base64 data - photo.webviewPath = `data:image/jpeg;base64,${file.data}`; + photo.webviewPath = `data:image/jpeg;base64,${readFile.data}`; } // CHANGE: Set the signal so the gallery view updates @@ -137,12 +137,12 @@ export class PhotoService { private PHOTO_STORAGE: string = 'photos'; - public async addNewToGallery() { + public async addNewToGallery(): Promise { // Take a photo const capturedPhoto = await Camera.getPhoto({ - resultType: CameraResultType.Uri, - source: CameraSource.Camera, - quality: 100, + resultType: CameraResultType.Uri, // file-based data; provides best performance + source: CameraSource.Camera, // automatically take a new photo with the camera + quality: 100, // highest quality (0 to 100) }); // Save the picture and add it to photo collection @@ -192,20 +192,20 @@ export class PhotoService { }); } - public async loadSaved() { + public async loadSaved(): Promise { // Retrieve cached photo array data const { value: photoList } = await Preferences.get({ key: this.PHOTO_STORAGE }); const photos = (photoList ? JSON.parse(photoList) : []) as UserPhoto[]; for (const photo of photos) { // Read each saved photo's data from the Filesystem - const file = await Filesystem.readFile({ + const readFile = await Filesystem.readFile({ path: photo.filepath, directory: Directory.Data, }); // Web platform only: Load the photo as base64 data - photo.webviewPath = `data:image/jpeg;base64,${file.data}`; + photo.webviewPath = `data:image/jpeg;base64,${readFile.data}`; } this.photos.set(photos); @@ -223,7 +223,8 @@ Our `PhotoService` can now load the saved images, but we'll need to update `tab2 Update `tab2.page.ts` to look like the following: ```ts -import { Component, OnInit, inject } from '@angular/core'; +import type { OnInit } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { IonHeader, IonToolbar, @@ -238,6 +239,7 @@ import { } from '@ionic/angular'; import { addIcons } from 'ionicons'; import { camera } from 'ionicons/icons'; + import { PhotoService } from '../services/photo.service'; @Component({ @@ -254,11 +256,11 @@ export class Tab2Page implements OnInit { } // CHANGE: Add call to `loadSaved()` when navigating to the Photos tab - async ngOnInit() { + async ngOnInit(): Promise { await this.photoService.loadSaved(); } - addPhotoToGallery() { + addPhotoToGallery(): void { this.photoService.addNewToGallery(); } } diff --git a/docs/angular/your-first-app/5-adding-mobile.mdx b/docs/angular/your-first-app/5-adding-mobile.mdx index 266a77fe8e..0ae243b190 100644 --- a/docs/angular/your-first-app/5-adding-mobile.mdx +++ b/docs/angular/your-first-app/5-adding-mobile.mdx @@ -61,8 +61,9 @@ private async savePicture(photo: Photo) { if (this.platform.is('hybrid')) { // Read the file into base64 format const file = await Filesystem.readFile({ - path: photo.path! + path: photo.path!, }); + base64Data = file.data; } else { base64Data = await this.base64FromPath(photo.webPath!); @@ -73,7 +74,7 @@ private async savePicture(photo: Photo) { const savedFile = await Filesystem.writeFile({ path: fileName, data: base64Data, - directory: Directory.Data + directory: Directory.Data, }); // Use webPath to display the new image instead of base64 since it's @@ -91,11 +92,11 @@ When running on mobile, set `filepath` to the result of the `writeFile()` operat import { Injectable, inject, signal } from '@angular/core'; import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import type { Photo } from '@capacitor/camera'; +// CHANGE: Add import +import { Capacitor } from '@capacitor/core'; import { Filesystem, Directory } from '@capacitor/filesystem'; import { Preferences } from '@capacitor/preferences'; import { Platform } from '@ionic/angular'; -// Change: Add import -import { Capacitor } from '@capacitor/core'; // ...existing code... ``` @@ -106,11 +107,13 @@ Then update `savePicture()` to look like the following: // CHANGE: Update `savePicture()` method private async savePicture(photo: Photo) { let base64Data: string | Blob; + // "hybrid" will detect mobile - iOS or Android if (this.platform.is('hybrid')) { const file = await Filesystem.readFile({ path: photo.path!, }); + base64Data = file.data; } else { base64Data = await this.base64FromPath(photo.webPath!); @@ -146,7 +149,7 @@ Next, add a new bit of logic in the `loadSaved()` method. On mobile, we can dire ```ts // CHANGE: Update `loadSaved()` method -public async loadSaved() { +public async loadSaved(): Promise { const { value: photoList } = await Preferences.get({ key: this.PHOTO_STORAGE }); const photos = (photoList ? JSON.parse(photoList) : []) as UserPhoto[]; @@ -154,13 +157,13 @@ public async loadSaved() { // If running on the web... if (!this.platform.is('hybrid')) { for (const photo of photos) { - const file = await Filesystem.readFile({ - path: photo.filepath, - directory: Directory.Data + const readFile = await Filesystem.readFile({ + path: photo.filepath, + directory: Directory.Data, }); // Web platform only: Load the photo as base64 data - photo.webviewPath = `data:image/jpeg;base64,${file.data}`; + photo.webviewPath = `data:image/jpeg;base64,${readFile.data}`; } } @@ -171,16 +174,16 @@ public async loadSaved() { Our Photo Gallery now consists of one codebase that runs on the web, Android, and iOS. -`photos.service.ts` should now look like this: +`photo.service.ts` should now look like this: ```ts import { Injectable, inject, signal } from '@angular/core'; import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import type { Photo } from '@capacitor/camera'; +import { Capacitor } from '@capacitor/core'; import { Filesystem, Directory } from '@capacitor/filesystem'; import { Preferences } from '@capacitor/preferences'; import { Platform } from '@ionic/angular'; -import { Capacitor } from '@capacitor/core'; @Injectable({ providedIn: 'root', @@ -192,12 +195,12 @@ export class PhotoService { private platform = inject(Platform); - public async addNewToGallery() { + public async addNewToGallery(): Promise { // Take a photo const capturedPhoto = await Camera.getPhoto({ - resultType: CameraResultType.Uri, - source: CameraSource.Camera, - quality: 100, + resultType: CameraResultType.Uri, // file-based data; provides best performance + source: CameraSource.Camera, // automatically take a new photo with the camera + quality: 100, // highest quality (0 to 100) }); const savedImageFile = await this.savePicture(capturedPhoto); @@ -266,7 +269,7 @@ export class PhotoService { }); } - public async loadSaved() { + public async loadSaved(): Promise { // Retrieve cached photo array data const { value: photoList } = await Preferences.get({ key: this.PHOTO_STORAGE }); const photos = (photoList ? JSON.parse(photoList) : []) as UserPhoto[]; @@ -274,12 +277,13 @@ export class PhotoService { // If running on the web... if (!this.platform.is('hybrid')) { for (const photo of photos) { - const file = await Filesystem.readFile({ + const readFile = await Filesystem.readFile({ path: photo.filepath, directory: Directory.Data, }); + // Web platform only: Load the photo as base64 data - photo.webviewPath = `data:image/jpeg;base64,${file.data}`; + photo.webviewPath = `data:image/jpeg;base64,${readFile.data}`; } } diff --git a/docs/angular/your-first-app/7-live-reload.mdx b/docs/angular/your-first-app/7-live-reload.mdx index 317d832406..d1eb0a8fc7 100644 --- a/docs/angular/your-first-app/7-live-reload.mdx +++ b/docs/angular/your-first-app/7-live-reload.mdx @@ -41,10 +41,10 @@ In `photo.service.ts`, add the `deletePhoto()` method. The selected photo is rem import { Injectable, inject, signal } from '@angular/core'; import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import type { Photo } from '@capacitor/camera'; +import { Capacitor } from '@capacitor/core'; import { Filesystem, Directory } from '@capacitor/filesystem'; import { Preferences } from '@capacitor/preferences'; import { Platform } from '@ionic/angular'; -import { Capacitor } from '@capacitor/core'; @Injectable({ providedIn: 'root', @@ -53,7 +53,7 @@ export class PhotoService { // ...existing code... // CHANGE: Add `deletePhoto()` method - public async deletePhoto(photo: UserPhoto, position: number) { + public async deletePhoto(photo: UserPhoto, position: number): Promise { // Remove this photo from the photos signal this.photos.update((photos) => photos.filter((_, index) => index !== position)); @@ -82,7 +82,8 @@ export interface UserPhoto { Next, in `tab2.page.ts`, implement the `showActionSheet()` method. We're adding two options: "Delete", which calls `PhotoService.deletePhoto()`, and "Cancel". The cancel button will automatically close the action sheet when assigned the "cancel" role. ```ts -import { Component, OnInit, inject } from '@angular/core'; +import type { OnInit } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { IonHeader, IonToolbar, @@ -100,7 +101,8 @@ import { import { addIcons } from 'ionicons'; // CHANGE: Register the `trash` and `close` icons used by the action sheet import { camera, trash, close } from 'ionicons/icons'; -// Change: Add import + +// CHANGE: Add import import type { UserPhoto } from '../services/photo.service'; import { PhotoService } from '../services/photo.service'; @@ -120,16 +122,16 @@ export class Tab2Page implements OnInit { addIcons({ camera, trash, close }); } - async ngOnInit() { + async ngOnInit(): Promise { await this.photoService.loadSaved(); } - addPhotoToGallery() { + addPhotoToGallery(): void { this.photoService.addNewToGallery(); } // CHANGE: Add `showActionSheet()` method - public async showActionSheet(photo: UserPhoto, position: number) { + public async showActionSheet(photo: UserPhoto, position: number): Promise { const actionSheet = await this.actionSheetController.create({ header: 'Photos', buttons: [ diff --git a/docs/api/popover.mdx b/docs/api/popover.mdx index e5bb2854f8..58cff2cf79 100644 --- a/docs/api/popover.mdx +++ b/docs/api/popover.mdx @@ -206,7 +206,7 @@ type PositionAlign = 'start' | 'center' | 'end'; | Esc | Closes the popover. | | Space or Enter | Clicks the focusable element. | -`ion-popover` は、 `button` プロパティを持つ `ion-item` 要素間を移動するための矢印キーを完全にサポートしています。最も一般的な使用例としては、デスクトップにフォーカスしたアプリケーションにおけるドロップダウンメニューとして使用することができます。基本的なキーボードのサポートに加え、次の表ではドロップダウンメニューの矢印キーのサポートについて詳しく説明します。 +`ion-popover` は、 `button` プロパティを持つ `ion-item` 要素間を移動するための矢印キーを完全にサポートしています。最も一般的な使用例としては、ドロップダウンメニューとして使用することができます。基本的なキーボードのサポートに加え、次の表ではドロップダウンメニューの矢印キーのサポートについて詳しく説明します。 | Key | Description | | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | diff --git a/docs/core-concepts/cross-platform.mdx b/docs/core-concepts/cross-platform.mdx index d2b7c30bd5..e0b5e62e9d 100644 --- a/docs/core-concepts/cross-platform.mdx +++ b/docs/core-concepts/cross-platform.mdx @@ -6,6 +6,8 @@ title: Cross Platform Ionic は、どんなプラットフォーム向けに開発を行う場合でも簡単に開発できるように構築されています。まさにクロスプラットフォームに対応したフレームワークです。つまり、Android、iOS、Electron、Progressive Web App(PWA)として実行することができ、すべて単一のコードベースで実装可能です。それぞれのプラットフォームにアプリを最適化するために、いくつか注意すべき点があります。 +Electron and desktop browsers can run Ionic apps, but desktop usage is not officially supported. Refer to [Browser Support](../reference/browser-support.mdx#mobile-platforms) for the officially supported platforms. + ## ハードウェア API {/* #hardware-apis */} ネイティブアプリには、カメラを起動したり、位置情報を取得したりといったデバイス内の情報を取得するための API を呼び出すことがよくあります。これらの API 呼び出し処理はウェブブラウザ環境では動きません。なぜならネイティブブリッジが存在しないからです。Ionic にはこの問題に対応するためのいくつかの方法があります。 diff --git a/docs/core-concepts/fundamentals.mdx b/docs/core-concepts/fundamentals.mdx index b4f32e73ee..c14ed593c8 100644 --- a/docs/core-concepts/fundamentals.mdx +++ b/docs/core-concepts/fundamentals.mdx @@ -38,6 +38,8 @@ Angular と`@ionic/angular`で構築するアプリは、[Angular Router](https: Web テクノロジで構築されたアプリ(Ionic アプリなど)の素晴らしい点は、デスクトップコンピュータ、携帯電話、タブレット、自動車、冷蔵庫など、ほとんどすべてのプラットフォームで実行できることです。Ionic アプリ用のベースとなるコードは、Web 標準とこれらのプラットフォーム間で共有される共通の API に基づいているため、多くのプラットフォームで機能します。 +That said, Ionic Framework is designed and tested for mobile platforms. Refer to [Browser Support](../reference/browser-support.mdx#mobile-platforms) for the platforms it officially supports. + Ionic の最も一般的な使用例の 1 つは、 [App Store](https://www.apple.com/ios/app-store/) と [Play Store](https://play.google.com/) の両方からダウンロードできるアプリを作成することです。iOS と Android の両方のソフトウェア開発キット(SDK)は、完全なネイティブ SDK アクセスを可能にしながら、あらゆる Ionic アプリをレンダリングする [Web Views](webview.mdx) を提供します。 {/* prettier-ignore */} diff --git a/docs/developing/hardware-back-button.mdx b/docs/developing/hardware-back-button.mdx index 4f07499686..379a796c73 100644 --- a/docs/developing/hardware-back-button.mdx +++ b/docs/developing/hardware-back-button.mdx @@ -310,18 +310,17 @@ document.addEventListener('ionBackButton', (event: BackButtonEvent) => { ```tsx -import { Optional } from '@angular/core'; +import { ViewChild } from '@angular/core'; import { IonRouterOutlet, Platform } from '@ionic/angular/lazy'; import { App } from '@capacitor/app'; ... -constructor( - private platform: Platform, - @Optional() private routerOutlet?: IonRouterOutlet -) { +@ViewChild(IonRouterOutlet, { static: true }) routerOutlet?: IonRouterOutlet; + +constructor(private platform: Platform) { this.platform.backButton.subscribeWithPriority(-1, () => { - if (!this.routerOutlet.canGoBack()) { + if (this.routerOutlet && !this.routerOutlet.canGoBack()) { App.exitApp(); } }); @@ -332,18 +331,17 @@ constructor( ```tsx -import { Optional } from '@angular/core'; +import { ViewChild } from '@angular/core'; import { IonRouterOutlet, Platform } from '@ionic/angular'; import { App } from '@capacitor/app'; ... -constructor( - private platform: Platform, - @Optional() private routerOutlet?: IonRouterOutlet -) { +@ViewChild(IonRouterOutlet, { static: true }) routerOutlet?: IonRouterOutlet; + +constructor(private platform: Platform) { this.platform.backButton.subscribeWithPriority(-1, () => { - if (!this.routerOutlet.canGoBack()) { + if (this.routerOutlet && !this.routerOutlet.canGoBack()) { App.exitApp(); } }); @@ -392,6 +390,8 @@ useBackButton(-1, () => { アプリケーションを終了する前に、ユーザーがルート・ページにあるかどうかを確認することをお勧めします。開発者は、Ionic Angular の `IonRouterOutlet` で`canGoBack` 、 Ionic React および Ionic Vue の `IonRouter` で `canGoBack` メソッドを使用できます。 +In Ionic Angular, `IonRouterOutlet` can only be injected into components rendered inside the outlet. The component whose template contains `ion-router-outlet`, such as `AppComponent`, should query it with `@ViewChild`, as demonstrated in the example above. + ## 内部フレームワークハンドラ {/* #internal-framework-handlers */} 次の表は、Ionic Framework が使用するすべての内部ハードウェアバックボタンイベントハンドラの一覧です。 `Propagates` 列は、その特定のハンドラが Ionic Framework に次の戻るボタンハンドラを呼び出すように指示するかどうかを示します。 diff --git a/docs/native-faq.mdx b/docs/native-faq.mdx index 827e9d46db..d6eb6d649d 100644 --- a/docs/native-faq.mdx +++ b/docs/native-faq.mdx @@ -3,7 +3,7 @@ sidebar_label: よくある質問 slug: /native/faq --- -#よくある質問 +# よくある質問 ## Capacitor とは何ですか? {/* #what-is-capacitor */} diff --git a/docs/react/quickstart.mdx b/docs/react/quickstart.mdx index 97289eb8c7..564269b847 100644 --- a/docs/react/quickstart.mdx +++ b/docs/react/quickstart.mdx @@ -78,9 +78,10 @@ Your new app's directory will look like this: The root of your app is defined in `App.tsx`: ```tsx title="src/App.tsx" -import { Route, Navigate } from 'react-router-dom'; import { IonApp, IonRouterOutlet, setupIonicReact } from '@ionic/react'; import { IonReactRouter } from '@ionic/react-router'; +import { Navigate, Route } from 'react-router-dom'; + import Home from './pages/Home'; // ..CSS imports... @@ -122,6 +123,7 @@ The Home page component, defined in `Home.tsx`, imports the Ionic components and ```tsx title="src/pages/Home.tsx" import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/react'; + import ExploreContainer from '../components/ExploreContainer'; import './Home.css'; @@ -295,9 +297,21 @@ Update `New.tsx` to add a `ref` on `IonContent` and a button and some items afte Then, add the imports for the additional components and define the `scrollToBottom` function: ```tsx title="src/pages/New.tsx" -import { useRef } from 'react'; -import { IonButton, IonBackButton, IonButtons, IonContent, IonHeader, IonIcon, IonItem, IonLabel, IonPage, IonTitle, IonToolbar } from '@ionic/react'; +import { + IonButton, + IonBackButton, + IonButtons, + IonContent, + IonHeader, + IonIcon, + IonItem, + IonLabel, + IonPage, + IonTitle, + IonToolbar, +} from '@ionic/react'; import { heart, logoIonic } from 'ionicons/icons'; +import { useRef } from 'react'; const New: React.FC = () => { const content = useRef(null); diff --git a/docs/react/testing/introduction.mdx b/docs/react/testing/introduction.mdx index 25698b2956..94ba66f2ae 100644 --- a/docs/react/testing/introduction.mdx +++ b/docs/react/testing/introduction.mdx @@ -10,7 +10,20 @@ This document provides an overview of how to test an application built with `@io ## Introduction {/* #introduction */} -Testing is an important part of the development process, and it helps to ensure that an application is working as intended. In `@ionic/react`, testing is done using a combination of tools and libraries, including Jest or Vitest, React Testing Library, Playwright or Cypress. +Testing is an important part of the development process, and it helps to ensure that an application is working as intended. Applications generated with the Ionic CLI are set up for unit testing with [Vitest](https://vitest.dev) and [React Testing Library](https://testing-library.com), and for end-to-end testing with [Playwright](https://playwright.dev). Unit tests live next to the code they test in `src` and end-to-end tests live in `e2e`. + +The generated `package.json` includes scripts for running them: + +| Script | Description | +| ------------------------- | ------------------------------------------------------------ | +| `npm test` | Runs the unit tests once, then the end-to-end tests. | +| `npm run test:unit` | Runs the unit tests once. | +| `npm run test:unit:watch` | Runs the unit tests and re-runs them when files change. | +| `npm run test:e2e` | Runs the end-to-end tests, starting the dev server for them. | + +:::note +Playwright downloads its browsers separately from the npm package. Run `npx playwright install` once before running the end-to-end tests for the first time. +::: ## Types of Tests {/* #types-of-tests */} @@ -18,4 +31,4 @@ There are two types of tests that can be written: **Unit Tests**: Unit tests are used to test individual functions and components in isolation. [Jest](https://jestjs.io), [Vitest](https://vitest.dev/) and [React Testing Library](https://testing-library.com) are commonly used for unit testing. -**Integration Tests**: Integration tests are used to test how different components work together. [Cypress](https://www.cypress.io) or [Playwright](https://playwright.dev) are commonly used for integration testing. +**End-to-end Tests**: End-to-end tests are used to test how different components work together in the running application. [Playwright](https://playwright.dev) and [Cypress](https://www.cypress.io) are commonly used for end-to-end testing. diff --git a/docs/react/testing/unit-testing/best-practices.mdx b/docs/react/testing/unit-testing/best-practices.mdx index 21b104ff91..dff52eeb13 100644 --- a/docs/react/testing/unit-testing/best-practices.mdx +++ b/docs/react/testing/unit-testing/best-practices.mdx @@ -10,7 +10,8 @@ In your test template when rendering with React Testing Library, you must wrap y ```tsx title="Example.test.tsx" import { IonApp } from '@ionic/react'; -import { render } from "@testing-library/react"; +import { render } from '@testing-library/react'; +import { test } from 'vitest'; import Example from './Example'; @@ -30,8 +31,9 @@ React Testing Library recommends using the `user-event` library for simulating u ```tsx title="Example.test.tsx" import { IonApp } from '@ionic/react'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { test } from 'vitest'; import Example from './Example'; @@ -55,9 +57,10 @@ test('example', async () => { Ionic コンポーネントがレンダリングされるのを待ってからその DOM に対してアサートする必要がある場合は、`@ionic/core`からエクスポートされる`componentOnReady`ヘルパーを使用してください。`el.componentOnReady()`を直接呼び出さないでください。`@ionic/react`は Stencil のカスタムエレメントビルドを使用しており、そのメソッドは要素上に存在しません。ヘルパーは代わりに 1 フレーム待機し、コンポーネントの内部コンテンツがレンダリングされる機会を与えます。 ```tsx +import { componentOnReady } from '@ionic/core'; import { IonApp } from '@ionic/react'; import { render } from '@testing-library/react'; -import { componentOnReady } from '@ionic/core'; +import { expect, test } from 'vitest'; import Example from './Example'; diff --git a/docs/react/testing/unit-testing/examples.mdx b/docs/react/testing/unit-testing/examples.mdx index 1b5a84ac10..9186aaf917 100644 --- a/docs/react/testing/unit-testing/examples.mdx +++ b/docs/react/testing/unit-testing/examples.mdx @@ -30,6 +30,7 @@ export default function Example() { ```tsx title="src/Example.test.tsx" import { IonApp } from '@ionic/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { expect, test } from 'vitest'; import Example from './Example'; @@ -92,6 +93,7 @@ export default Example; ```tsx title="src/Example.test.tsx" import { IonApp } from '@ionic/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { expect, test } from 'vitest'; import Example from './Example'; diff --git a/docs/react/testing/unit-testing/setup.mdx b/docs/react/testing/unit-testing/setup.mdx index aae81f5703..2ca4acd782 100644 --- a/docs/react/testing/unit-testing/setup.mdx +++ b/docs/react/testing/unit-testing/setup.mdx @@ -6,35 +6,79 @@ description: Learn how to set up unit tests for an Ionic React application. # Unit Testing Setup -Ionic requires a few additional steps to set up unit tests. If you are using an Ionic starter project, these steps have already been completed for you. +Ionic requires a few additional steps to set up unit tests. If you are using an Ionic starter project, Vitest, React Testing Library, and jest-dom are already installed and `src/setupTests.ts` is already configured; you only need to initialize Ionic React as described below. + +### Install Vitest {/* #install-vitest */} + +[Vitest](https://vitest.dev) runs the tests. It uses [jsdom](https://github.com/jsdom/jsdom) to provide a browser-like environment for the components. + +```bash +npm install --save-dev vitest jsdom +``` + +Then configure Vitest in `vite.config.ts` to use jsdom and load `src/setupTests.ts` before the tests run: + +```ts title="vite.config.ts" +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + setupFiles: './src/setupTests.ts', + include: ['src/**/*.test.{ts,tsx}'], + }, +}); +``` ### Install React Testing Library {/* #install-react-testing-library */} React Testing Library is a set of utilities that make it easier to test React components. It's used to interact with components and test their behavior. ```bash -npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event +npm install --save-dev @testing-library/react @testing-library/dom @testing-library/jest-dom +``` + +To simulate user interactions with [`user-event`](./best-practices.mdx#use-user-event-for-user-interactions), install it as well. It is not included in Ionic starter projects: + +```bash +npm install --save-dev @testing-library/user-event ``` ### Initialize Ionic React {/* #initialize-ionic-react */} Ionic React requires the `setupIonicReact` function to be called before any tests are run. Failing to do so will result in mode-based classes and platform behaviors not being applied to your components. -In `src/setupTest.ts`, add the following code: +In `src/setupTests.ts`, add the following code: ```diff -import '@testing-library/jest-dom/extend-expect'; - +// Adds jest-dom's DOM matchers (toBeInTheDocument, toHaveTextContent, ...) to Vitest's expect. +import '@testing-library/jest-dom/vitest'; + import { setupIonicReact } from '@ionic/react'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + setupIonicReact(); ++ +// Testing Library only cleans up automatically when Vitest globals are enabled. +afterEach(() => { + cleanup(); +}); -// Mock matchmedia -window.matchMedia = window.matchMedia || function () { - return { - matches: false, - addListener: function () { }, - removeListener: function () { } +// jsdom doesn't implement matchMedia, which Ionic uses for platform and dark mode checks. +window.matchMedia = + window.matchMedia || + function (query: string): MediaQueryList { + return { + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + }; }; -}; ``` diff --git a/docs/react/your-first-app.mdx b/docs/react/your-first-app.mdx index 47804415dc..6b5f082471 100644 --- a/docs/react/your-first-app.mdx +++ b/docs/react/your-first-app.mdx @@ -99,15 +99,15 @@ npm install @ionic/pwa-elements Next, import `@ionic/pwa-elements` by editing `src/main.tsx`. ```tsx +// CHANGE: Add the following import +import { defineCustomElements } from '@ionic/pwa-elements/loader'; import React from 'react'; import { createRoot } from 'react-dom/client'; + import App from './App'; -// CHANGE: Add the following import -import { defineCustomElements } from '@ionic/pwa-elements/loader'; // CHANGE: Call the element loader before the render call defineCustomElements(window); - const container = document.getElementById('root'); const root = createRoot(container!); root.render( @@ -139,6 +139,7 @@ There are three tabs. Click on the "Tab2" tab. It’s a blank canvas, aka the pe ```tsx import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/react'; + import ExploreContainer from '../components/ExploreContainer'; import './Tab2.css'; @@ -191,10 +192,11 @@ export default Tab2; We put the visual aspects of our app into ``. In this case, it’s where we’ll add a button that opens the device’s camera as well as displays the image captured by the camera. Start by adding a [floating action button](../api/fab.mdx) (FAB) to the bottom of the page and set the camera image as the icon. ```tsx -// CHANGE: Add the following import -import { camera } from 'ionicons/icons'; // CHANGE: Update the following import import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar, IonFab, IonFabButton, IonIcon } from '@ionic/react'; +// CHANGE: Add the following import +import { camera } from 'ionicons/icons'; + // CHANGE: Remove or comment out `ExploreContainer` // import ExploreContainer from '../components/ExploreContainer'; import './Tab2.css'; @@ -234,7 +236,6 @@ export default Tab2; Next, open `src/App.tsx`. Change the label to "Photos" and the `ellipse` icon to `images` for the middle tab button. ```tsx -import { Route, Navigate } from 'react-router-dom'; import { IonApp, IonIcon, @@ -248,12 +249,16 @@ import { import { IonReactRouter } from '@ionic/react-router'; // CHANGE: Update the following import import { images, square, triangle } from 'ionicons/icons'; +import { Navigate, Route } from 'react-router-dom'; + import Tab1 from './pages/Tab1'; import Tab2 from './pages/Tab2'; import Tab3 from './pages/Tab3'; /* ...existing Ionic styles... */ +setupIonicReact(); + const App: React.FC = () => ( diff --git a/docs/react/your-first-app/2-taking-photos.mdx b/docs/react/your-first-app/2-taking-photos.mdx index 31a97b3825..c833cbe168 100644 --- a/docs/react/your-first-app/2-taking-photos.mdx +++ b/docs/react/your-first-app/2-taking-photos.mdx @@ -24,13 +24,15 @@ Next, define a new method, `usePhotoGallery()`, that will contain the core logic ```ts import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; -export function usePhotoGallery() { +export function usePhotoGallery(): { + addNewToGallery: () => Promise; +} { const addNewToGallery = async () => { // Take a photo const capturedPhoto = await Camera.getPhoto({ - resultType: CameraResultType.Uri, - source: CameraSource.Camera, - quality: 100, + resultType: CameraResultType.Uri, // file-based data; provides best performance + source: CameraSource.Camera, // automatically take a new photo with the camera + quality: 100, // highest quality (0 to 100) }); }; @@ -45,8 +47,9 @@ Notice the magic here: there's no platform-specific code (web, iOS, or Android)! Next, in `Tab2.tsx`, import the `usePhotoGallery()` method and destructure it to call its `addNewToGallery()` method. ```tsx -import { camera } from 'ionicons/icons'; import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar, IonFab, IonFabButton, IonIcon } from '@ionic/react'; +import { camera } from 'ionicons/icons'; + // CHANGE: Add `usePhotoGallery` import import { usePhotoGallery } from '../hooks/usePhotoGallery'; import './Tab2.css'; @@ -96,7 +99,9 @@ After taking a photo, it disappears right away. We need to display it within our To define the data structure for our photo metadata, create a new interface named `UserPhoto`. Add this interface at the very bottom of the `usePhotoGallery.ts` file, immediately after the `usePhotoGallery()` method definition. ```ts -export function usePhotoGallery() { +export function usePhotoGallery(): { + addNewToGallery: () => Promise; +} { // ...existing code... } @@ -110,11 +115,13 @@ export interface UserPhoto { Above the `addNewToGallery()` method, define an array of `UserPhoto`, which will contain a reference to each photo captured with the Camera. Make it a state variable using React's [useState hook](https://react.dev/reference/react/useState). ```ts +import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; // CHANGE: Add import import { useState } from 'react'; -import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; -export function usePhotoGallery() { +export function usePhotoGallery(): { + addNewToGallery: () => Promise; +} { // CHANGE: Add the `photos` array const [photos, setPhotos] = useState([]); @@ -125,20 +132,24 @@ export function usePhotoGallery() { Over in the `addNewToGallery()` method, add the newly captured photo to the beginning of the `photos` array. Then, update the `usePhotoGallery()` return statement with the `photos` array. ```ts -export function usePhotoGallery() { +export function usePhotoGallery(): { + addNewToGallery: () => Promise; + // CHANGE: Add `photos` to the return type + photos: UserPhoto[]; +} { const [photos, setPhotos] = useState([]); // CHANGE: Update `addNewToGallery()` method const addNewToGallery = async () => { // Take a photo const capturedPhoto = await Camera.getPhoto({ - resultType: CameraResultType.Uri, - source: CameraSource.Camera, - quality: 100, + resultType: CameraResultType.Uri, // file-based data; provides best performance + source: CameraSource.Camera, // automatically take a new photo with the camera + quality: 100, // highest quality (0 to 100) }); // CHANGE: Create the `fileName` with current timestamp - const fileName = Date.now() + '.jpeg'; + const fileName = new Date().getTime() + '.jpeg'; // CHANGE: Create `savedImageFile` matching `UserPhoto` interface const savedImageFile = [ { @@ -163,21 +174,24 @@ export function usePhotoGallery() { `usePhotoGallery.ts` should now look like this: ```ts -import { useState } from 'react'; import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; +import { useState } from 'react'; -export function usePhotoGallery() { +export function usePhotoGallery(): { + addNewToGallery: () => Promise; + photos: UserPhoto[]; +} { const [photos, setPhotos] = useState([]); const addNewToGallery = async () => { // Take a photo const capturedPhoto = await Camera.getPhoto({ - resultType: CameraResultType.Uri, - source: CameraSource.Camera, - quality: 100, + resultType: CameraResultType.Uri, // file-based data; provides best performance + source: CameraSource.Camera, // automatically take a new photo with the camera + quality: 100, // highest quality (0 to 100) }); - const fileName = Date.now() + '.jpeg'; + const fileName = new Date().getTime() + '.jpeg'; const savedImageFile = [ { filepath: fileName, @@ -204,7 +218,6 @@ export interface UserPhoto { Next, switch to `Tab2.tsx` to display the images. We'll add a [Grid component](../../api/grid.mdx) to ensure the photos display neatly as they're added to the gallery. Inside the grid, loop through each photo in the `UserPhoto`'s `photos` array. For each item, add an `` element and set its `src` property to the photo's path. ```tsx -import { camera } from 'ionicons/icons'; // CHANGE: Update import import { IonContent, @@ -219,7 +232,10 @@ import { IonRow, IonCol, } from '@ionic/react'; +import { camera } from 'ionicons/icons'; + import { usePhotoGallery } from '../hooks/usePhotoGallery'; +import './Tab2.css'; const Tab2: React.FC = () => { // CHANGE: Add `photos` array to destructure from `usePhotoGallery()` diff --git a/docs/react/your-first-app/3-saving-photos.mdx b/docs/react/your-first-app/3-saving-photos.mdx index e874484342..9505c10ca5 100644 --- a/docs/react/your-first-app/3-saving-photos.mdx +++ b/docs/react/your-first-app/3-saving-photos.mdx @@ -18,12 +18,15 @@ We’re now able to take multiple photos and display them in a photo gallery on Fortunately, saving them to the filesystem only takes a few steps. Begin by creating a new class method, `savePicture()`, in the `usePhotoGallery()` method in `usePhotoGallery.ts`. ```ts -import { useState } from 'react'; -import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; // CHANGE: Add import import type { Photo } from '@capacitor/camera'; +import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; +import { useState } from 'react'; -export function usePhotoGallery() { +export function usePhotoGallery(): { + addNewToGallery: () => Promise; + photos: UserPhoto[]; +} { // ...existing code... // CHANGE: Add the `savePicture()` method @@ -49,22 +52,25 @@ export interface UserPhoto { We can use this new method immediately in `addNewToGallery()`. ```ts -import { useState } from 'react'; -import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import type { Photo } from '@capacitor/camera'; +import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; +import { useState } from 'react'; -export function usePhotoGallery() { +export function usePhotoGallery(): { + addNewToGallery: () => Promise; + photos: UserPhoto[]; +} { const [photos, setPhotos] = useState([]); const addNewToGallery = async () => { // Take a photo const capturedPhoto = await Camera.getPhoto({ - resultType: CameraResultType.Uri, - source: CameraSource.Camera, - quality: 100, + resultType: CameraResultType.Uri, // file-based data; provides best performance + source: CameraSource.Camera, // automatically take a new photo with the camera + quality: 100, // highest quality (0 to 100) }); - const fileName = Date.now() + '.jpeg'; + const fileName = new Date().getTime() + '.jpeg'; // CHANGE: Add `savedImageFile` // Save the picture and add it to photo collection const savedImageFile = await savePicture(capturedPhoto, fileName); @@ -100,13 +106,16 @@ Then, pass the data to the Filesystem's `writeFile` method. Recall that we displ Create a new helper method, `base64FromPath()`, to implement the necessary logic for running on the web: ```ts -import { useState } from 'react'; -import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import type { Photo } from '@capacitor/camera'; +import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; // CHANGE: Add import import { Filesystem, Directory } from '@capacitor/filesystem'; +import { useState } from 'react'; -export function usePhotoGallery() { +export function usePhotoGallery(): { + addNewToGallery: () => Promise; + photos: UserPhoto[]; +} { // ...existing code... // CHANGE: Update the `savePicture()` method @@ -160,23 +169,26 @@ export interface UserPhoto { `usePhotoGallery.ts` should now look like this: ```ts -import { useState } from 'react'; -import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import type { Photo } from '@capacitor/camera'; +import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import { Filesystem, Directory } from '@capacitor/filesystem'; +import { useState } from 'react'; -export function usePhotoGallery() { +export function usePhotoGallery(): { + addNewToGallery: () => Promise; + photos: UserPhoto[]; +} { const [photos, setPhotos] = useState([]); const addNewToGallery = async () => { // Take a photo const capturedPhoto = await Camera.getPhoto({ - resultType: CameraResultType.Uri, - source: CameraSource.Camera, - quality: 100, + resultType: CameraResultType.Uri, // file-based data; provides best performance + source: CameraSource.Camera, // automatically take a new photo with the camera + quality: 100, // highest quality (0 to 100) }); - const fileName = Date.now() + '.jpeg'; + const fileName = new Date().getTime() + '.jpeg'; // Save the picture and add it to photo collection const savedImageFile = await savePicture(capturedPhoto, fileName); diff --git a/docs/react/your-first-app/4-loading-photos.mdx b/docs/react/your-first-app/4-loading-photos.mdx index 43f0433f17..21565e7b11 100644 --- a/docs/react/your-first-app/4-loading-photos.mdx +++ b/docs/react/your-first-app/4-loading-photos.mdx @@ -20,7 +20,10 @@ Fortunately, this is easy: we’ll leverage the Capacitor [Preferences API](/nat Open `usePhotoGallery.ts` and begin by defining a constant variable that will act as the key for the store. ```ts -export function usePhotoGallery() { +export function usePhotoGallery(): { + addNewToGallery: () => Promise; + photos: UserPhoto[]; +} { const [photos, setPhotos] = useState([]); // CHANGE: Add a key for photo storage const PHOTO_STORAGE = 'photos'; @@ -32,14 +35,17 @@ export function usePhotoGallery() { Next, at the end of the `addNewToGallery()` method, add a call to the `Preferences.set()` method to save the `photos` array. By adding it here, the `photos` array is stored each time a new photo is taken. This way, it doesn’t matter when the app user closes or switches to a different app - all photo data is saved. ```ts -import { useState } from 'react'; -import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import type { Photo } from '@capacitor/camera'; +import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import { Filesystem, Directory } from '@capacitor/filesystem'; // CHANGE: Add import import { Preferences } from '@capacitor/preferences'; +import { useState } from 'react'; -export function usePhotoGallery() { +export function usePhotoGallery(): { + addNewToGallery: () => Promise; + photos: UserPhoto[]; +} { // ...existing code... const addNewToGallery = async () => { @@ -61,14 +67,17 @@ export function usePhotoGallery() { With the photo array data saved, create a new method in the `usePhotoGallery()` called `loadSaved()` that can retrieve the photo data. We use the same key to retrieve the `photos` array in JSON format, then parse it into an array. ```ts -// CHANGE: Update import -import { useState, useEffect } from 'react'; -import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import type { Photo } from '@capacitor/camera'; +import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import { Filesystem, Directory } from '@capacitor/filesystem'; import { Preferences } from '@capacitor/preferences'; +// CHANGE: Update import +import { useState, useEffect } from 'react'; -export function usePhotoGallery() { +export function usePhotoGallery(): { + addNewToGallery: () => Promise; + photos: UserPhoto[]; +} { const [photos, setPhotos] = useState([]); const PHOTO_STORAGE = 'photos'; @@ -93,7 +102,10 @@ The second parameter, the empty dependency array (`[]`), is what tells React to On mobile (coming up next!), we can directly set the source of an image tag - `` - to each photo file on the `Filesystem`, displaying them automatically. On the web, however, we must read each image from the `Filesystem` into base64 format, using a new `base64` property on the `Photo` object. This is because the `Filesystem` API uses [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) under the hood. Add the following code to complete the `loadSaved()` method. ```ts -export function usePhotoGallery() { +export function usePhotoGallery(): { + addNewToGallery: () => Promise; + photos: UserPhoto[]; +} { const [photos, setPhotos] = useState([]); const PHOTO_STORAGE = 'photos'; @@ -126,13 +138,16 @@ export function usePhotoGallery() { `usePhotoGallery.ts` should now look like this: ```ts -import { useState, useEffect } from 'react'; -import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import type { Photo } from '@capacitor/camera'; +import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import { Filesystem, Directory } from '@capacitor/filesystem'; import { Preferences } from '@capacitor/preferences'; +import { useState, useEffect } from 'react'; -export function usePhotoGallery() { +export function usePhotoGallery(): { + addNewToGallery: () => Promise; + photos: UserPhoto[]; +} { const [photos, setPhotos] = useState([]); const PHOTO_STORAGE = 'photos'; @@ -159,12 +174,12 @@ export function usePhotoGallery() { const addNewToGallery = async () => { // Take a photo const capturedPhoto = await Camera.getPhoto({ - resultType: CameraResultType.Uri, - source: CameraSource.Camera, - quality: 100, + resultType: CameraResultType.Uri, // file-based data; provides best performance + source: CameraSource.Camera, // automatically take a new photo with the camera + quality: 100, // highest quality (0 to 100) }); - const fileName = Date.now() + '.jpeg'; + const fileName = new Date().getTime() + '.jpeg'; // Save the picture and add it to photo collection const savedImageFile = await savePicture(capturedPhoto, fileName); diff --git a/docs/react/your-first-app/5-adding-mobile.mdx b/docs/react/your-first-app/5-adding-mobile.mdx index d8b1281732..0b5abf1615 100644 --- a/docs/react/your-first-app/5-adding-mobile.mdx +++ b/docs/react/your-first-app/5-adding-mobile.mdx @@ -22,13 +22,15 @@ Import the Ionic [Platform API](../platform.mdx) into `usePhotoGallery.ts`, whic Add `isPlatform` to the imports at the top of the file to use the `isPlatform` method. `Capacitor` is also imported to help with file paths on mobile devices. ```ts -import { useState, useEffect } from 'react'; -import { Camera, CameraResultType, CameraSource, Photo } from '@capacitor/camera'; +import type { Photo } from '@capacitor/camera'; +import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; +// CHANGE: Add import +import { Capacitor } from '@capacitor/core'; import { Filesystem, Directory } from '@capacitor/filesystem'; import { Preferences } from '@capacitor/preferences'; // CHANGE: Add import import { isPlatform } from '@ionic/react'; -import { Capacitor } from '@capacitor/core'; +import { useState, useEffect } from 'react'; // ...existing code... ``` @@ -49,7 +51,7 @@ const savePicture = async (photo: Photo, fileName: string): Promise = const file = await Filesystem.readFile({ path: photo.path!, }); - base64Data = typeof file.data === 'string' ? file.data : await file.data.text(); + base64Data = file.data; } else { base64Data = await base64FromPath(photo.webPath!); } @@ -107,15 +109,18 @@ Our Photo Gallery now consists of one codebase that runs on the web, Android, an `usePhotoGallery.ts` should now look like this: ```ts -import { useState, useEffect } from 'react'; -import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import type { Photo } from '@capacitor/camera'; +import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; +import { Capacitor } from '@capacitor/core'; import { Filesystem, Directory } from '@capacitor/filesystem'; import { Preferences } from '@capacitor/preferences'; import { isPlatform } from '@ionic/react'; -import { Capacitor } from '@capacitor/core'; +import { useState, useEffect } from 'react'; -export function usePhotoGallery() { +export function usePhotoGallery(): { + addNewToGallery: () => Promise; + photos: UserPhoto[]; +} { const [photos, setPhotos] = useState([]); const PHOTO_STORAGE = 'photos'; @@ -145,12 +150,12 @@ export function usePhotoGallery() { const addNewToGallery = async () => { // Take a photo const capturedPhoto = await Camera.getPhoto({ - resultType: CameraResultType.Uri, - source: CameraSource.Camera, - quality: 100, + resultType: CameraResultType.Uri, // file-based data; provides best performance + source: CameraSource.Camera, // automatically take a new photo with the camera + quality: 100, // highest quality (0 to 100) }); - const fileName = Date.now() + '.jpeg'; + const fileName = new Date().getTime() + '.jpeg'; // Save the picture and add it to photo collection const savedImageFile = await savePicture(capturedPhoto, fileName); @@ -167,7 +172,7 @@ export function usePhotoGallery() { const file = await Filesystem.readFile({ path: photo.path!, }); - base64Data = typeof file.data === 'string' ? file.data : await file.data.text(); + base64Data = file.data; } else { base64Data = await base64FromPath(photo.webPath!); } diff --git a/docs/react/your-first-app/7-live-reload.mdx b/docs/react/your-first-app/7-live-reload.mdx index 77d128c7fd..0ca68eea7f 100644 --- a/docs/react/your-first-app/7-live-reload.mdx +++ b/docs/react/your-first-app/7-live-reload.mdx @@ -38,15 +38,20 @@ With Live Reload running and the app open on your device, let’s implement phot In `usePhotoGallery.ts`, add the `deletePhoto()` method. The selected photo is removed from the `photos` array first. Then, we delete the actual photo file itself using the Filesystem API. ```ts -import { useState, useEffect } from 'react'; -import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import type { Photo } from '@capacitor/camera'; +import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; +import { Capacitor } from '@capacitor/core'; import { Filesystem, Directory } from '@capacitor/filesystem'; import { Preferences } from '@capacitor/preferences'; import { isPlatform } from '@ionic/react'; -import { Capacitor } from '@capacitor/core'; +import { useState, useEffect } from 'react'; -export function usePhotoGallery() { +export function usePhotoGallery(): { + addNewToGallery: () => Promise; + // CHANGE: Add `deletePhoto()` to the return type + deletePhoto: (photo: UserPhoto) => Promise; + photos: UserPhoto[]; +} { // ...existing code... // CHANGE: Add `deletePhoto()` method @@ -68,10 +73,10 @@ export function usePhotoGallery() { }; return { - photos, addNewToGallery, // CHANGE: Add `deletePhoto()` to the return statement deletePhoto, + photos, }; } @@ -84,10 +89,6 @@ export interface UserPhoto { Next, in `Tab2.tsx`, implement the `IonActionSheet` component. We're adding two options: "Delete", which calls `usePhotoGallery.deletePhoto()`, and "Cancel". The cancel button will automatically close the action sheet when assigned the "cancel" role. ```tsx -// CHANGE: Add import -import { useState } from 'react'; -// CHANGE: Update import -import { camera, trash, close } from 'ionicons/icons'; // CHANGE: Update import import { IonContent, @@ -103,6 +104,11 @@ import { IonCol, IonActionSheet, } from '@ionic/react'; +// CHANGE: Update import +import { camera, trash, close } from 'ionicons/icons'; +// CHANGE: Add import +import { useState } from 'react'; + // CHANGE: Add import import type { UserPhoto } from '../hooks/usePhotoGallery'; import { usePhotoGallery } from '../hooks/usePhotoGallery'; diff --git a/docs/reference/browser-support.mdx b/docs/reference/browser-support.mdx index d49b6d1181..613382a8d8 100644 --- a/docs/reference/browser-support.mdx +++ b/docs/reference/browser-support.mdx @@ -37,6 +37,12 @@ Android 5.0 から、Webview は Android から独立して更新できる別の ## ブラウザ {/* #browsers */} +:::note + +Ionic apps can run in desktop browsers, and nothing in Ionic Framework prevents it. However, Ionic is designed and tested for the [mobile platforms](#mobile-platforms) listed above, so desktop usage is not officially supported. + +::: + Ionic はこれらのブラウザをサポートしています: | Browser | Ionic v9 | Ionic v8 | Ionic v7 | Ionic v6 | Ionic v5 | Ionic v4 | diff --git a/docs/vue/navigation.mdx b/docs/vue/navigation.mdx index a576f3ef62..b51c0f49c0 100644 --- a/docs/vue/navigation.mdx +++ b/docs/vue/navigation.mdx @@ -31,10 +31,11 @@ Vue Router を使ったルーティングで知っていることはすべて Io ```tsx import { createRouter, createWebHistory } from '@ionic/vue-router'; -import { RouteRecordRaw } from 'vue-router'; +import type { RouteRecordRaw } from 'vue-router'; + import HomePage from '@/views/Home.vue'; -const routes: Array = [ +const routes: RouteRecordRaw[] = [ { path: '/', name: 'Home', @@ -57,7 +58,7 @@ export default router; 最初ロードされたパスに別のパスを設定したい場合はどうすればよいでしょうか。これには、ルータリダイレクトを使用できます。リダイレクトは通常のルートオブジェクトと同じように機能しますが、いくつかの異なるキーが含まれています。 ```tsx -const routes: Array = [ +const routes: RouteRecordRaw[] = [ { path: '/', redirect: '/home', @@ -77,7 +78,7 @@ const routes: Array = [ これは素晴らしいことですが、実際にルートにナビゲートするにはどうすればよいのでしょうか。これには、 `router-link` プロパティを使用できます。新しいルーティング設定を作成します: ```tsx -const routes: Array = [ +const routes: RouteRecordRaw[] = [ { path: '/', redirect: '/home', @@ -185,7 +186,7 @@ If you were to call `router.go(-2)` on `/pageC`, you would be brought back to `/ 現在の route の設定方法では、アプリをロードするときに同じ初期チャンクに含まれるようになっているが、これは必ずしも理想的ではありません。代わりに、必要に応じてコンポーネントがロードされるように route を設定できます。 ```tsx -const routes: Array = [ +const routes: RouteRecordRaw[] = [ { path: '/', redirect: '/home', @@ -278,7 +279,7 @@ A common point of confusion when setting up routing is deciding between shared U 共有 URL は、ルートが共通の URL の一部を持つルート設定です。共有 URL 設定の例を次に示します: ```tsx -const routes: Array = [ +const routes: RouteRecordRaw[] = [ { path: '/dashboard', component: DashboardMainPage, @@ -297,7 +298,7 @@ const routes: Array = [ Nested Routes は、ルートが他のルートの子としてリストされるルート設定です。ネストされたルート設定の例を次に示します: ```tsx -const routes: Array = [ +const routes: RouteRecordRaw[] = [ { path: '/dashboard/:id', component: DashboardRouterOutlet, @@ -330,7 +331,7 @@ const routes: Array = [ タブを扱う際、Ionic Vue はどのビューがどのタブに属しているかを認識する方法が必要です。ここで役立つのが `IonTabs` コンポーネントですが、そのルーティング設定を見てみましょう: ```tsx -const routes: Array = [ +const routes: RouteRecordRaw[] = [ { path: '/', redirect: '/tabs/tab1', @@ -410,7 +411,7 @@ Ionic は開発者がモバイルアプリを作るのを助けることに重 When adding additional routes to tabs you should write them as sibling routes with the parent tab as the path prefix. The example below defines the `/tabs/tab1/view` route as a sibling of the `/tabs/tab1` route. Since this new route has the `tab1` prefix, it will be rendered inside of the `Tabs` component, and Tab 1 will still be selected in the `ion-tab-bar`. ```tsx -const routes: Array = [ +const routes: RouteRecordRaw[] = [ { path: '/', redirect: '/tabs/tab1', @@ -530,7 +531,7 @@ For example usages, please refer to our [Utility Functions](utility-functions#us 元のルーティング例を拡張して、URL パラメータをどのように使えるか示しましょう。コンポーネントがルーターへの直接参照を必要としないよう、URL パラメータは [props として渡すこと](https://router.vuejs.org/guide/essentials/passing-props.html) を推奨します。これにより、コンポーネントを再利用したり、単独でテストしたりするのが容易になります。 ```tsx -const routes: Array = [ +const routes: RouteRecordRaw[] = [ { path: '/', redirect: '/home', diff --git a/docs/vue/quickstart.mdx b/docs/vue/quickstart.mdx index 7a3d9f52b4..78b854acd9 100644 --- a/docs/vue/quickstart.mdx +++ b/docs/vue/quickstart.mdx @@ -95,10 +95,11 @@ Routes are defined in `router/index.ts`: ```ts title="src/router/index.ts" import { createRouter, createWebHistory } from '@ionic/vue-router'; -import { RouteRecordRaw } from 'vue-router'; +import type { RouteRecordRaw } from 'vue-router'; + import HomePage from '../views/HomePage.vue'; -const routes: Array = [ +const routes: RouteRecordRaw[] = [ { path: '/', redirect: '/home', @@ -234,7 +235,7 @@ import NewPage from '../views/NewPage.vue'; Then, add its route in the `routes` array: ```ts title="src/router/index.ts" -const routes: Array = [ +const routes: RouteRecordRaw[] = [ { path: '/', redirect: '/home', @@ -367,24 +368,59 @@ Ionic Vue projects are created with TypeScript by default, but you can easily co 1. Remove the TypeScript dependencies: ```shell -npm uninstall --save typescript @types/jest @typescript-eslint/eslint-plugin @typescript-eslint/parser @vue/cli-plugin-typescript @vue/eslint-config-typescript vue-tsc +npm uninstall --save typescript @types/node @ionic/eslint-config @vue/eslint-config-typescript vue-tsc ``` -2. Change the extension of all `.ts` files to `.js`. In a blank Ionic Vue app, this will be the `src/router/index.ts`, `src/main.ts`, and files in the `tests` directory. +2. Change the extension of all `.ts` files to `.js`, except `src/vite-env.d.ts`, which is deleted in a later step. In a blank Ionic Vue app, this will be the `src/router/index.ts`, `src/main.ts`, `vite.config.ts`, `playwright.config.ts`, and files in the `tests` directory. 3. In `index.html`, change the imported `